Skip to content

Sync with upstream NASA-AMMOS/development#73

Draft
slesaad wants to merge 104 commits into
NASA-IMPACT:developmentfrom
NASA-AMMOS:development
Draft

Sync with upstream NASA-AMMOS/development#73
slesaad wants to merge 104 commits into
NASA-IMPACT:developmentfrom
NASA-AMMOS:development

Conversation

@slesaad

@slesaad slesaad commented Apr 21, 2026

Copy link
Copy Markdown
Member

Summary

Sync fork's development branch with upstream NASA-AMMOS/development.

Notable upstream changes included:

Test plan

  • Review upstream commit range for conflicts with IMPACT fork changes
  • Verify build succeeds after merge
  • Verify Playwright/unit tests pass
  • Smoke test core map rendering (Leaflet + Cesium)

ac-61 and others added 30 commits February 17, 2026 10:22
* Fix bug in viewer_open kind

* chore: bump version to 4.2.10-20260217 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Add config option to set initial zoom in mobile mode

* chore: bump version to 4.2.11-20260217 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* #868 Improve Dockerfile

* #868 Fix docker-build workflow

* #868 clean up python-environment.yml
* #871 Bug: DyanmicExtent+Threshold Layers do not properly update

* chore: bump version to 4.2.12-20260226 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Add font types as asset to webpack

* chore: bump version to 4.2.12-20260226 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Make the toolbar icons larger in mobile mode

* Make height of LayerTool, LegendTool, and InfoTool dynamic in mobile mode

* Hide Locate button in LayersTool

* Fix toolbar bug

* chore: bump version to 4.2.13-20260226 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Fix the height of the topbar in mobile mode

* chore: bump version to 4.2.14-20260227 [version bump]

* Fix size of TimeUI input boxes so it works with smaller screens in mobile mode

* Update bg color and move css declarations

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* #863 Allow linking to external MMGIS stac catalogs

* chore: bump version to 4.2.9-20260212 [version bump]

* #863 External STAC: support queryTilesetTime

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Security fixes

* chore: bump version to 4.2.16-20260302 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Fix security issues 2

* chore: bump version to 4.2.17-20260305 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Improve login page for smaller screens

* chore: bump version to 4.2.17-20260304 [version bump]

* Bump package

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Fix bug in viewer_open kind

* chore: bump version to 4.2.17-20260304 [version bump]

* Use correct variable

* Bump version

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* #886 [Bug]: Fix Initial Start and End Time configurations parameters

* chore: bump version to 4.2.20-20260309 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Remove redundant urlencoded

* chore: bump version to 4.2.21-20260310 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Update time and timetype metaconfigs

* chore: bump version to 4.2.22-20260310 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* #892 Fix queryTilesetTimes does not update on layer toggles

* chore: bump version to 4.2.23-20260310 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…896)

* #895 Fix DrawTool bugs, template field naming, not null adv filters

* chore: bump version to 4.2.24-20260311 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Upgrade Adjacent Servers and sample ENVs

* chore: bump version to 4.2.25-20260318 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* #898 Support TiTiler layers in Cesium Globe

* chore: bump version to 4.2.26-20260318 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Add .gitattributes

* chore: bump version to 4.2.27-20260319 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Ensure _docker-entrypoint.sh uses LF line endings.
* Fix image loading in OpenSeadragon

* chore: bump version to 4.2.19-20260318 [version bump]

* Fix bug

* Bump version

* Bump version

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* #904 Release AnalysisTool and OperationsClock

* Add ATTRIBUTIONS.md
tariqksoliman and others added 30 commits April 28, 2026 10:04
* #952 Improve dataset endpoint error catching

* chore: bump version to 4.3.27-20260428 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* feat: Add 3D extrusion for vectortile layers + 3D Tiles support

  Adds two new 3D rendering capabilities to the Cesium globe renderer:

  1. Vector tile extrusion: vectortile layers can now render extruded 3D
     buildings on the globe via a new "3D Extrusion" tab in the layer
     config. A CesiumMVTLayer class manages tile lifecycle (load, decode,
     evict) with batched Cesium.Primitive rendering and per-feature color
     support from the OpenMapTiles schema. No Cesium ion token required —
     works with any MVT source (OpenFreeMap, Versatiles, self-hosted).

  2. 3D Tiles layer type: new "3dtiles" layer type for Cesium3DTileset
     URLs, with configurable LOD, memory limits, height offset, and
     style expressions.

  Also:

  - New auxiliary/resolve-tile-url CLI utility to resolve TileJSON
    endpoints to concrete tile URLs (keeps MMGIS provider-agnostic).
  - Scene lighting enabled with a fixed sun angle (summer solstice, 10am
    EDT) for consistent, readable building shading.
  - L.vectorGrid sublayer filtering in Map_.js to hide MVT sublayers not
    explicitly styled (prevents default blue rendering of roads, water,
    etc.).
  - Backend validation (API/Backend/Config/validate.js) accepts the new
    3dtiles type.

* feat: terrain-aware building placement + MVT simplification

- CesiumMVTLayer now samples terrain height at each tile center so
  extruded buildings sit on the ground surface instead of at elevation 0.
  Tries globe.getHeight() first (fast, synchronous), falls back to
  fetching the Mapzen Terrarium tile directly and decoding heights from
  the PNG. Cached per terrain tile to avoid re-fetching.

- Added SimplifiedVectorGrid: subclass of L.VectorGrid.Protobuf that
  applies Douglas-Peucker simplification to polygon rings after decode,
  before SVG rendering. Reduces vertex counts ~50-80% on dense sources
  like OSM buildings with no perceptible visual change. Opt-in via a
  simplifyTolerance option; inline algorithm, no new dependencies.

- Map_.js wires SimplifiedVectorGrid into the vectortile flow for
  layers with extrusion enabled (default tolerance: 4 MVT units).
* #955 Fix login pathing for external proxies

* chore: bump version to 4.3.28-20260505 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Fix clearGradientHoverPoint missing in mockLitho

* chore: bump version to 4.3.29-20260505 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…iltering (#961)

* #960 Fix DrawTool - Templated point type points do not respect time filtering

* chore: bump version to 4.3.30-20260507 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Added minimum specifications for system requirements.
#967)

* #966 Poor configure/upsert SQL and websocket body causes memory spike.

* chore: bump version to 4.3.31-20260511 [version bump]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* Fix DEM tile corruption by using nearest-neighbor resampling for RGBA-encoded float tiles

When --dem flag is used, float32 elevation values are encoded into RGBA bytes
via IEEE 754 binary representation. The scale_query_to_tile function was using
gdal.RegenerateOverview with 'average' resampling, which averages the raw RGBA
byte values. Since these bytes represent IEEE 754 float encoding, averaging them
produces garbage float values (e.g. 6.54e+27 instead of ~150m elevation).

This is especially visible at tile edges where valid RGBA-encoded pixels neighbor
transparent (0,0,0,0) nodata pixels.

Fix: when options.isDEMtile is True, use 'near' (nearest-neighbor) resampling
instead of 'average' in scale_query_to_tile. This preserves the RGBA byte
encoding integrity during the querysize-to-tile_size downscale step.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.19-20260422 [version bump]

* Regenerate Reference-Mission RGBA DEM tiles with fixed gdal2customtiles.py

Tiles regenerated at zoom levels 10-14 using the corrected script that uses
nearest-neighbor resampling for DEM tiles instead of average. All 23,660 data
pixels verified to decode to valid elevations within the source DEM range.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add production fail-safe checks, test DB credential separation, and AI agent safety rules

- Add NODE_ENV=production and DATABASE_URL production-indicator checks
  to tests/global-setup.js and tests/test-db-clean.js
- Support DB_USER_TEST / DB_PASS_TEST env vars for least-privilege
  test database credential separation
- Add Database Safety Rules section to AGENTS.md and AI-GETTING-STARTED.md
- Create .cursorrules with database safety guidelines for AI agents

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.26-20260427 [version bump]

* remove .cursorrules — not used

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: handle promise rejection from clean() safety checks

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove DATABASE_URL check, update error wording, require explicit test creds in test-db-clean

- Remove DATABASE_URL production check (no such ENV exists)
- Change 'destructive test operations' to 'test operations' in error message
- test-db-clean.js now requires DB_USER_TEST/DB_PASS_TEST with no fallback

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add mmgis-stac-test DB isolation and STAC_DB_NAME env var

- Make STAC DB name configurable via STAC_DB_NAME in API/connection.js
  and scripts/init-db.js (defaults to 'mmgis-stac')
- global-setup.js creates mmgis-stac-test when STAC services are enabled
  and passes STAC_DB_NAME to the test server
- Adjacent server .env files rewritten to use mmgis-stac-test
- test-db-clean.js drops mmgis-stac-test alongside mmgis-test
- Add DB_USER_TEST, DB_PASS_TEST, STAC_DB_NAME to sample.env and ENVs.md
- Update safety rules in AGENTS.md and AI-GETTING-STARTED.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: comment out empty env vars in sample.env, fix STAC cleanup independence

- Comment out DB_USER_TEST, DB_PASS_TEST, STAC_DB_NAME in sample.env to
  prevent dotenv from setting them to empty/whitespace values
- Fix early return in test-db-clean.js so mmgis-stac-test cleanup runs
  independently of whether mmgis-test exists

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: move test env vars to Optional Variables section in ENVs.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: require DB_USER_TEST/DB_PASS_TEST in global-setup.js (no fallback)

- Remove fallback to DB_USER/DB_PASS in global-setup.js credential resolution
- Add DB_USER_TEST/DB_PASS_TEST to CI workflow .env setup
- Update ENVs.md to reflect these are now required for tests

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove STAC_DB_NAME env var, hardcode mmgis-stac and mmgis-stac-test

- Revert API/connection.js to hardcoded 'mmgis-stac'
- Revert scripts/init-db.js to hardcoded 'mmgis-stac'
- Test infrastructure uses hardcoded 'mmgis-stac-test' constant
- Remove STAC_DB_NAME from sample.env and ENVs.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: update sample.env comment — test creds required in both files, no fallback

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: add windowsHide to suppress console windows on Windows

Prevents execSync and spawn calls in global-setup.js from flashing
empty terminal windows on Windows machines.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Re-apply 36 UI improvements on development's React architecture

Adapted all 36 UI tasks from PR #47 to work with development's full
React component architecture (Toolbar, Splitter, BottomBarReact, etc.)
instead of PR #45's null-stub + jQuery layout.

Layout & Positioning (Tasks 1, 4, 5, 11, 16, 17, 20, 27):
- BottomElementPositioner: immediate positioning with toolPanelWidth offset
- Scalebar/compass: 12px permanent right push + tool panel width
- Toolbar: flex-direction column layout

Controls & Navigation (Tasks 2, 9, 10):
- Map zoom: Home button resets to configured initial view
- BottomBarReact: reduced to About + Copy Link only
- TopBar: kebab menu with Screenshot, Fullscreen, Hotkeys, Settings

Tool System (Tasks 3, 6, 7, 13):
- Tool buttons: CSS classes (.toolButtonActive) instead of inline styles
- Tool headers: standardized with mmgisToolHeader/mmgisToolTitle classes
- MeasureTool: close button at top, reset/download at bottom

Visual Polish (Tasks 8, 14, 18, 25, 26, 29, 30, 32, 34, 36):
- Splitter: invisible by default, accent color on hover
- Compass: smooth left transition
- Coordinates: background, separator between mouse/pick
- mmgislogo: border-right matching topbar border-bottom
- toolsWrapper: no border in bottomFloatingBar

TimeUI & Settings (Tasks 15, 23, 24, 28):
- toggleTimeUI button removed from coordinates
- TimeUI toggle added to Settings modal
- Popover positioning uses window.innerHeight - bcr.top

Modals & Config (Tasks 12, 19, 21):
- About modal: MMGIS logo, version, attributions, markdown content
- Modal close: proper click handler with stopPropagation
- Attributions: removed from DOM, collected for About modal

Panel Sync (Task 31):
- TopBar panel toggles sync with Zustand store via subscribe

Dependencies & Config:
- Added dompurify for markdown sanitization
- Added aboutModalContent to config template and Reference Mission
- Added 9 help markdown files for tools

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add theming system (6 presets), floating tool panel, CSS variable support

- Port design-system: themes.js (6 presets), applyTheme.js, themeApplier.js, useTheme.js
- Wire theme into Zustand store (themeName + setTheme action)
- Wire theme into UserInterfaceBridge init/fina and Stylize.js
- Add theme dropdown to configure page (look.theme field)
- Update mmgisUI.css to use CSS variables for theme support
- Make tool panel float over map instead of docking/pushing
- Hide splitter drag handles by default
- Fix Coordinates.css margin for removed TimeUI button

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix useTheme.js for React 16 compatibility (useState/useEffect instead of useSyncExternalStore)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix layout to match PR #47: floating tool panel, bottom bar, toolbar, topbar

- ToolPanel: 12px inset margins from toolbar/topbar, border-radius 10px,
  backdrop-filter blur(20px), opacity transition on open/close
- Toolbar: box-shadow, z-index 101, 34x34 tool buttons with border-radius 8px,
  starts below topbar (top: topSize), Toolbar.css imported
- TopBar: solid background + border-bottom via themeApplier, box-shadow via CSS
- Bottom floating bar: new BottomFloatingBar wraps toolsWrapper + timeUIDock
  inside #splitscreens with 12px margins, backdrop blur, border-radius
- TimeUI reparented into floating bar via MutationObserver
- BottomElementPositioner: recalculated offsets based on floating bar height
- Separated tools: offset by toolPanelWidth + 24px when tool panel is open
- Splitter arrow buttons hidden (panel toggles in TopBar replace them)
- FloatingElements.css: backdrop blur for TimeUI, coordinates, compass, zoom
- SplitScreens.css, BottomBarReact.css, ToolPanel.css, Toolbar.css added

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TopBar: stays full-width when tool panel opens (matches PR #47)

- Remove toolPanelWidth/toolsWrapperRawWidth reactive styles that shifted
  TopBar right when tool panel opened
- TopBar now always uses left:0, width:100%, padding-left:40px (from CSS)
- Tool panel floats underneath TopBar (z-index 2005 > 1400)
- Add theme colors for toggle buttons, user avatar via CSS variables
- Remove redundant z-index override in TopBar.css

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix ToolPanel drag handle offset: use panelLeftOffset instead of hardcoded 10

The drag handle's left position uses panelLeftOffset (52px on desktop)
but the drag calculation was using 10 as offset, causing width to
inflate by 42px after each drag-resize.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TopBar solid background and splitscreens positioning below TopBar

- Remove minimalist(true) call from fina() — matches PR #47 which also
  removed it. Splitscreens now correctly starts at top:40px (topSize)
  instead of top:0px which caused map to render under the TopBar.
- Add background: var(--color-a) and border-bottom: 1px solid var(--color-a1)
  to #topBar CSS so it's solid from initial render (themeApplier.js
  overrides on theme change).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Port separated tools and Legend changes from PR #47

- SeparatedTools.jsx returns null — separated tool rendering moved to
  ToolController_.js jQuery DOM construction (matches PR #47)
- ToolController_.init() now creates floating glassmorphism panels for
  separated tools (Legend, Identifier) with header/close buttons
- Separated tool buttons appear in toolbar below a divider
- LegendTool.js header updated to use mmgisToolHeader/mmgisToolTitle
  classes with Help integration
- tab-ui-config.json: removed Default Tool section, renamed Colors to
  Advanced Color Overrides
- Maker.js: removed defaulttooldropdown component type

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Show MMGIS logo by default (was hidden behind minimalist() call)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add tool panel close button, separated tool improvements

- Add injectCloseButton() to ToolController_ with X button in upper right
  of tool panel (both vertical and horizontal tools)
- Call injectCloseButton() after tool.make() in makeTool()
- Add tippy tooltips to separated tool buttons (Legend, Identifier)
- Add divider background color using CSS variable
- Add isMobile check to skip separated tools on mobile (matching PR #47)
- Order Legend button last in separated tools section (matching PR #47)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix bottom bar layout for all 4 tool/TimeUI combinations

- Remove direct CSS manipulation from TimeUI._updateBottomUIHeight(),
  delegate to centralized BottomElementPositioner via Zustand store
- Add React close button to BottomFloatingBar for horizontal tools
  (replaces jQuery injection that was wiped by React re-renders)
- Skip jQuery close button injection for horizontal tools since React
  BottomFloatingBar now handles it
- Add leaflet-bottom-left positioning to BottomElementPositioner
- All 4 combos verified: no tool/collapsed, no tool/expanded,
  measure/collapsed, measure/expanded

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix 13 UI/layout issues in PR #48

1. Remove redundant loginDiv and loginoutButton (hidden via useEffect)
2. mmgislogo border-right now uses var(--color-a1) matching topbar border-bottom
3. About button moved below Copy Link in toolbar bottom
4. Separated tools (Legend, Identifier) now appear in toolbar via SepToolsContainer
5. Map zoom/home controls use solid background matching topbar/toolbar
6. bottomFloatingBar z-index raised to 1500 (above toolpanel 1400)
7. Compass offset reduced from +38 to +8 (closer to scalebar)
8. mmgisToolHeader now flex row so MeasureTool [Title ? ... undo reset] layout works
9. Toolbar z-index raised to 2006 (above topbar 2005, prevents box-shadow overlap)
10. Light theme text contrast: CoordinatesDiv, topBarMain, TimeUI buttons, DrawTool
11. TimeUI timeline colors now update with theme changes via themeApplier
12. DrawTool tabs visible: #drawToolNotLoggedIn now starts at top:81px below nav
13. Bottom floating bar left offset accounts for tool panel width

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix 16 UI issues (#14-29)

14. Bottom bar no longer pushed by vertical tool panel
15. LayersTool header restored (reverted mmgisToolHeader flex, fix MeasureTool inline)
16. Separated Legend panel pushed right when vertical tool opens
17. Legend appears before Identifier in toolbar
18. loginDiv/loginoutButton hidden via CSS !important
19. Horizontal tool panel width fixed (always 100% of bottom bar)
20. Identifier tool button no longer auto-activates on load
21. Splitters use theme accent color (--color-mmgis)
22. Vertical toolpanel drag edge styled like splitters
23. Horizontal toolpanel drag handle (6px accent bar at top)
24. Compass positioning simplified (flows with leaflet container)
25. Light theme text: Coordinates.css uses var(--color-f), themeApplier strengthened
26. Globe toggle: Globe_.init() instead of non-existent lazyInit()
27. Separated Legend panel theme: headers and content text use theme colors
28. InfoTool null guard on destroy (MMGISInterface may be null)
29. Splitter drag no longer offset by tool panel width (panel floats now)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix computeToolsSplitMoveResult min clamp to preserve splitterSize/4 fallback

The test expects splitterSize/4 as minimum when toolNativeHeight is not set.
Use Math.max(toolNativeHeight, splitterSize/4) so both constraints apply.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix 8 UI issues (#30-37)

30. Remove duplicate close button (React BottomFloatingBar one removed; tool's own stays)
31. Splitters/drag handles transparent by default, accent color on hover/drag only
32. Horizontal tool splitter follows mouse (disable CSS transition during drag via isDraggingSplitter flag)
33. Legend pushed right by extra 12px gap when vertical tool panel open
34. Compass (leaflet-bottom-left) pushed right when vertical tool panel opens
35. TimeUI mode dropdown z-index raised above tool panel
36. Glassy backdrop-filter on floating elements (CoordinatesDiv container transparent)
37. CoordinatesDiv: height 30px, bottom/right 12px permanent offset

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* React 18 upgrade + Base UI adoption + CSS Modules migration

- Upgrade react and react-dom from ^16.13.1 to ^18.2.0
- Upgrade react-app-polyfill to ^3.0.0, react-resize-detector to ^9.1.0
- Migrate entry point (src/index.js) to createRoot API
- Fix ReactDOM.render in CurtainTool.js and PDFViewer.js
- Fix ReactDOM.createPortal import in UserInterfaceLayout.jsx
- Install @base-ui-components/react@1.0.0-rc.0

Design system wrapper components (src/design-system/components/):
- Button.jsx + Button.module.css (primary/secondary/ghost variants)
- IconButton.jsx + IconButton.module.css
- Dropdown.jsx + Dropdown.module.css (wraps Base UI Menu)
- Toggle.jsx + Toggle.module.css (panel toggle groups)
- Modal.jsx + Modal.module.css (wraps Base UI Dialog)
- Tooltip.jsx + Tooltip.module.css (wraps Base UI Tooltip)

CSS Modules migration for shell components:
- TopBar: Rewritten with Toggle and Dropdown components, CSS Modules
- ToolPanel: CSS Modules, replaced hardcoded rgba with color-mix()
- SplitScreens: CSS Modules, replaced hardcoded rgba with theme vars
- Splitter: Added Splitter.module.css
- Toolbar: Migrated to CSS Modules
- BottomBarReact: Migrated to CSS Modules
- UserInterfaceLayout: Migrated to CSS Modules

themeApplier.js cleanup:
- Removed imperative selectors for #topBar, #toolPanel, #bottomFloatingBar,
  #topBarTitleName, #topBarRight icons, #topBarMain (now CSS Modules)
- Kept selectors for jQuery/Leaflet/TimeUI elements

webpack.config.js: Added namedExport: false for css-loader v7 compatibility

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Upgrade react-chartjs-2 to ^4.3.1 for React 18 peer dep compatibility

react-chartjs-2@3.3.0 only supported react ^16.8.0 || ^17.0.0.
v4.3.1 adds react ^18.0.0 support while keeping chart.js ^3.5.0 compat.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix #topBarInfo ID conflict: rename About button to #bottomBarAbout

The About button was reusing the #topBarInfo ID, which caused:
1. UserInterfaceBridge.js visibility logic hiding it unless look.info/infourl set
2. Stylize.js jQuery click handler opening infourl simultaneously with About modal

Renamed to #bottomBarAbout so it doesn't conflict with the legacy #topBarInfo
jQuery handler. The original #topBarInfo info-URL behavior remains untouched.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Address Devin Review findings: infourl, theme override, dead code

- Add look.infourl as a row in the About modal so it remains accessible
- Remove dead jQuery click handler for #topBarInfo in Stylize.js
- Remove duplicate setTheme() in UserInterfaceBridge.fina() that was
  clobbering individual color overrides already applied by Stylize.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix config visibility toggles for kebab menu items + clean up dead handlers

- Add lookConfig to Zustand store, set from UserInterfaceBridge.fina()
- TopBar: conditionally render Screenshot, Fullscreen, Settings, Copy Link
  dropdown items based on lookConfig (screenshot, fullscreen, settings, copylink)
- BottomBarReact: conditionally render Copy Link button based on lookConfig
- Remove dead jQuery click handlers for #topBarHelp and #topBarInfo in Stylize.js
- Remove dead DOM getElementById calls in UserInterfaceBridge for elements that
  no longer exist (topBarScreenshot, topBarFullscreen, bottomBarSettings, etc.)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix toggleTimeUI() reading state from removed #toggleTimeUI element

The #toggleTimeUI button was removed from the Coordinates markup but
toggleTimeUI() still read active state from it. Since the element doesn't
exist, $('#toggleTimeUI').hasClass('active') always returns false, causing:
- Map_.map._fadeAnimated always set to false (fade animations never re-enabled)
- L_._onTimeUIToggleSubscriptions always told TimeUI is being turned ON

Fix: read state from #timeUI (which exists and tracks the active class)
instead of #toggleTimeUI.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Improve Base UI adoption and reduce :global() overuse

- TopBar: use IconButton for sign-in/kebab buttons, use Dropdown for user card
  popup (replaces hand-rolled outside-click + manual popup with Base UI Menu
  for keyboard nav, focus trap, accessibility)
- BottomBarReact: use IconButton + Tooltip instead of raw <i> + tippy.js
- Toolbar: use Tooltip instead of tippy.js for tool button tooltips
- Reduce :global() from 33 to 22 instances — all remaining are justified by
  external jQuery/imperative code references:
  - SplitScreens: convert #viewerScreen/#globeScreen to scoped classes
  - Toolbar: convert #toolcontroller_incdiv to scoped class
  - Splitter: convert .splitterV to scoped class
  - UserInterfaceLayout: consolidate TopBar styles into TopBar.module.css
  - BottomBarReact: fully scoped (IconButton handles all button styling)

Design system usage: 5 of 6 wrappers now imported across 3 files
(Toggle, Dropdown, IconButton, Tooltip — only Button/Modal unused,
which is appropriate since there are no standalone button/modal cases
in the main site shell).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix: remove stale setShowUserCard call in handleLogout

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Migrate Ancillary UI components from jQuery to React/native DOM

- Modal.js: React-based with imperative API bridge (Modal.set/remove)
  so existing jQuery callers (BottomBar, DrawTool, AnimationTool, etc.)
  work without changes. Uses React state + createRoot for rendering.

- ConfirmationModal.js: Removed jQuery dependency, uses native DOM
  event listeners with the React Modal backend.

- Help.js: Removed jQuery dependency, uses native fetch() + DOM
  event listeners instead of $.get() and $().on().

- ContextMenu.js: Removed jQuery dependency entirely. Uses native
  DOM APIs (createElement, addEventListener, querySelectorAll) for
  building and managing the right-click context menu.

- Compass.js: Removed jQuery dependency. Uses native DOM APIs
  (getElementById, querySelector) for compass element creation
  and bearing updates.

- MapLogo.js: Removed jQuery dependency. Uses native DOM APIs
  for logo element creation and Leaflet container insertion.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Migrate Ancillary components to true React + Base UI

- Modal.js: React component using Base UI Dialog with shared state store,
  imperative Modal.set()/remove() API preserved for backwards compatibility.
  Accepts both HTML strings (legacy callers) and React elements (new callers).

- ConfirmationModal.js: React JSX component using design-system Button for
  Yes/No actions, rendered through Modal service. Same prompt() API.

- Help.js: React JSX component using native fetch + showdown for markdown
  rendering, rendered through Modal service. Same getComponent/finalize API.

- ContextMenu.js: Full React component with createRoot, JSX menu items with
  proper event handlers. Same init()/remove() API.

- Compass.js: React component rendered via createRoot into Leaflet
  bottom-left container. SVG compass with bearing rotation on map events.

- MapLogo.js: React component rendered via createRoot into Leaflet
  bottom-right container. Configurable size and link support.

All components use CSS Modules. Old plain CSS files removed.

Also fixes:
- About modal now respects look.help/look.info config flags
- Cleaned up dead #toggleTimeUI references in Coordinates.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix modal, tooltip, measure, and context menu bugs

- Modal: Fix About modal not opening due to async createRoot in React 18.
  ModalHost now initializes from shared state and syncs on mount.
  Blur management moved to ModalHost (React state-driven), fixing
  persistent blur after modal close.

- Tooltip/Dropdown: Use render prop on Trigger to avoid nesting
  <button> inside <button> (IconButton is already a button element).

- MeasureTool: Migrate from deprecated ReactDOM.render to createRoot.
  Register Chart.js scales via Chart.register(...registerables) to fix
  'linear is not a registered scale' error with react-chartjs-2 v4.

- ContextMenu: Fix crash when right-clicking on LithoSphere scene
  (native events lack originalEvent). Store element and handler refs
  for proper cleanup, preventing listener leaks.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Modal: remove close button, fix blur persistence, add fade animation

- Remove the Dialog.Close button (curved bottom-left border-radius)
- Drop Base UI Dialog wrapper entirely — it was fighting with the
  imperative Modal.set/remove API. Now uses simple divs with CSS
  transitions, matching the original jQuery modal behavior exactly.
- Blur management is now purely imperative via _applyBlur() called
  synchronously in set() and remove(). Removed async useEffect approach.
- Add 500ms CSS opacity fade-in/fade-out transition matching original.
- Closing state: Modal.remove() marks modal as closing (triggers
  opacity 0 transition), then removes from DOM after 500ms.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename Ancillary React components to .jsx, fix modal blur via Zustand store, fix IconButton forwardRef, add Help.finalize calls

- Rename Modal, ConfirmationModal, Help, ContextMenu, Compass, MapLogo from .js to .jsx
- Route modal blur through Zustand modalBlurCount instead of imperative DOM manipulation
- Remove conflicting jQuery blur animation in Layers_.js
- Wrap IconButton with React.forwardRef to fix Tooltip/Menu trigger warnings
- Add Help.finalize() calls to ChemistryTool, DrawTool, IsochroneTool
- Change aboutModalContent config type from 'markdown' to 'textarea'

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert aboutModalContent type back to 'markdown' — Maker.js already handles it

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Help.jsx: check res.ok on fetch, sanitize HTML with DOMPurify

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix ContextMenu WKT null guard, fix Modal blur timing during fade-out

- Add null check for feature in handleActionClick WKT placeholder handling
- Delay blur removal until after 500ms fade-out completes (blur stays in sync with backdrop opacity)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Reorganize folder structure: dissolve Ancillary, nest components

- Dissolve src/essence/Ancillary/ entirely
  - UI components → UserInterface_/components/ (Modal, ConfirmationModal, Help,
    ContextMenu, Compass, MapLogo, CursorInfo, Attributions, Login)
  - Layout chrome → UserInterface_/components/ (Description, Coordinates, Search,
    ScaleBar, ScaleBox)
  - Pure services → essence/services/ (DataShaders, LocalFilterer, QueryURL, Sprites)
  - Stylize.js → design-system/ (theme bridge alongside themeApplier)
  - Delete unused Swap.js

- Nest all components into own folders (ComponentName/ComponentName.ext pattern):
  - UserInterface_/components/: TopBar/, Toolbar/, ToolPanel/, SplitScreens/,
    Splitter/, BottomBar/, BottomElementPositioner/, Layout/, Panels/
  - design-system/components/: Button/, IconButton/, Dropdown/, Toggle/, Modal/,
    Tooltip/

- Update ~70+ import paths across codebase
- Build verified locally

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Modal.set() race condition and themeApplier CSS variable override issue

- Modal.set() onAddCallback: Replace 50ms setTimeout with MutationObserver
  that waits for the modal element to appear in DOM before firing callback.
  Prevents silent jQuery binding failures on slower devices.

- themeApplier: Use Proxy to read computed CSS custom properties (set by
  Stylize.js per-mission overrides) instead of hardcoded theme object values.
  Stylize.js now calls refreshThemeDOM() after setting CSS variables so
  inline styles reflect mission-specific color overrides.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Sync toggleTimeUI DOM state to Zustand store

toggleTimeUI() now calls setTimeUIActive() and setTimeUIExpanded()
so BottomFloatingBar visibility and BottomElementPositioner offsets
reflect actual TimeUI state.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore #toggleTimeUI element in Coordinates markup

The element was accidentally dropped during the folder restructure move.
TimeUI.js and DrawTool.js check $('#toggleTimeUI').hasClass('active')
to gate histogram rendering and time-filter toggle visibility.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix #toggleTimeUI: restore click handler, tippy, active class; remove redundant jQuery positioning

Restored pieces lost during folder restructure:
- Click handler in init() and off handler in remove()
- Tippy tooltip for the time toggle button
- display:none when time is not enabled
- $('#toggleTimeUI').toggleClass('active') so TimeUI.js can check it
- $('#CoordinatesDiv > #toggleTimeUI').remove() on mobile

Removed jQuery CSS positioning from toggleTimeUI() since
BottomElementPositioner now reactively handles all bottom-anchored
element offsets via the Zustand store.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix CurtainTool.destroy() using undefined ReactDOM.unmountComponentAtNode

Use the stored _reactRoot.unmount() instead, matching the React 18
createRoot pattern already used in make().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix tooltips, scale indicator position, modal blur, help close button

Tooltips:
- Reduce Base UI Tooltip delay from 600ms (default) to 200ms
- Restyle tooltip popup to match tippy blue theme (var(--color-c2))
- Add Tooltip wrappers to TopBar panel toggles (Viewer/Map/Globe)
- Wrap Toggle with forwardRef so Tooltip render prop can attach ref
- Remove title attrs that conflicted with custom tooltips

Scale indicator:
- Remove scalefactor-specific positioning from BottomElementPositioner
  (it moves naturally with .leaflet-bottom.leaflet-left container)
- Position scalefactor to the left of compass at same bottom level

Modal blur:
- Call _applyBlur() immediately when marking modal as closing
  so blur clears during fade-out instead of persisting 500ms

Help modal:
- Add close (X) button in title bar matching other modal patterns

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Remove dead CSS: delete tools.css, clean ~600 lines from mmgisUI.css and mmgis.css

- Delete tools.css entirely (both selectors #CurtainToolList and
  .searchToolSelect are unreferenced anywhere in the codebase)
- Remove from mmgisUI.css: .mmgisRadioBar3/4/Vertical (140 lines),
  .mmgispureselect (104 lines), blink/condemned_blink_effect (38 lines),
  .slidecontainer/.slider (41 lines), .ar_slider (91 lines),
  .verticalSlider (91 lines), .mmgisMultirange_elev (19 lines),
  .ui-corner-all/bottom/right/br (9 lines)
- Remove from mmgis.css: #nodeenv, empty #topBar{}, #topBarInfo,
  #topBarHelp, #topBarFullscreen, #toggleUI, #logoGoBack
- Keep #topBarLink (used in BottomBarReact.jsx), #webgl-error-message
  (used by vendored THREE.js)
- All selectors verified with repo-wide grep before removal

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI fixes: tooltips, splitter hover, mobile toolbar, color schemes

- Replace Base UI Tooltip with simple React portal tooltip (200ms delay,
  tippy-matching style) — fixes missing tooltips for toolbar/topbar/bottom buttons
- Add cursor + hover highlight to vertical splitters (was missing because
  module CSS didn't inherit global .splitterV styles)
- Add hover highlight to tool panel drag handle
- Remove mdi-drag-vertical icon from tool panel drag
- Add mobile toolbar horizontal layout via @media query overrides
- Add 4 new color schemes: High Contrast (a11y), Dark Mars, Dark Midnight,
  Light Warm (total: 10 themes)
- Previous fixes also included in working tree:
  - timeUI border moved to toolsWrapper border-bottom (conditional)
  - #toggleTimeUI button removed entirely
  - CoordinatesDiv: vertical centering, unified background, 12px right offset
  - barBottom padding-bottom: 8px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix assignment operator used instead of comparison in TimeControl.fina()

Pre-existing bug: `TimeControl.enabled = true` was assigning instead of
comparing. Changed to `TimeControl.enabled === true`.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restructure Configure page UI tab: add all themes, Custom mode, enableWhenField

- Added all 10 theme presets to dropdown (was missing Dark Mars, Dark Midnight,
  Light Warm, High Contrast)
- Added 'Custom' option: skips preset theme, uses only color picker values
- Moved Theming section directly under Rebranding
- Nested 'Custom Color Options' under Theming with subdescription
- Added enableWhenField support to Maker.js: disables color pickers unless
  theme is set to Custom
- Renamed color options with clearer names and improved descriptions:
  Primary → Surface Color, Secondary → Deep Background Color,
  Tertiary → Text Color, Body → Page Body Color, Highlight → Feature Highlight
- Stylize.js: skip setTheme() when theme is 'Custom'
- Rebuilt configure page

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert tooltips to tippy.js, fix dropdown menus, redesign About modal

1. Tooltip: Replaced custom React portal tooltip with tippy.js wrapper.
   Uses the existing tippy.js dependency and 'blue' theme for consistency.

2. Dropdown: Replaced Base UI Menu with native portal dropdown.
   Base UI's nested Menu.Trigger + BaseButton composition was swallowing
   click events, breaking userAvatar and menuBtn menus. New implementation
   uses simple state + createPortal with proper outside-click dismissal.

3. About modal: Professional redesign with centered MMGIS ASCII art header,
   proper GitHub SVG logo link, clean metadata section, centered link
   buttons, attributions section, and NASA-AMMOS footer.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore .mmgisHelpButton base styles lost during Help.css module migration

The global .mmgisHelpButton styles (yellow color, compact 18x18px sizing,
0.7 opacity) were removed when Help.css was converted to Help.module.css.
Since Help.getComponent() emits raw HTML strings for jQuery-rendered tool
headers, it cannot use CSS Module scoped classes. Restored the base styles
in mmgis.css alongside the related .mmgisToolHelpBtn definition.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix session logout regression, About modal refinements, High Contrast theme, Stylize.js, Default Tool config

- Login: skip session.regenerate() for token-based re-auth (useToken:true)
  so reloading the main page no longer invalidates the configure page session
- About modal: replace ASCII art with mmgis.png logo, rename Attributions to
  Map Layer Attributions, remove footer logo, link NASA-AMMOS to ammos.nasa.gov
- High Contrast theme: change accent from #ffff00 to #ffd700 (gold) for better
  contrast ratios against dark backgrounds
- Stylize.js: color overrides only apply when theme is Custom or unset,
  preventing preset themes from being clobbered by stale config values
- Restore Default Tool config section in tab-ui-config.json (accidentally
  removed during Theming section reorganization)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore defaulttooldropdown case handler in Maker.js

The case was accidentally removed during the Configure page UI tab
restructure (d7f96c50). Without it, the Default Tool dropdown in the
Configure page rendered as nothing despite the config referencing it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* High Contrast tooltip text, panel toggle styling, scale position swap

- High Contrast theme: tooltips now use black text on yellow background
  via --color-c2-text variable (white for all other themes)
- About modal links use var(--color-f) for consistent theme text color
- Panel toggle buttons: 11px uppercase with 600 weight for better
  visibility
- Mapping scale button moved to bottom-right of compass (was top-left)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Reposition Viewer and Globe panel buttons to top-right

- Viewer: dropdown selector at top-right edge, OSD buttons stacked
  vertically below it; settings panel opens to the left
- Globe: home, exaggerate, observe, walk, link controls moved from
  TopLeft to TopRight corner via addControl 4th arg
- Style consistency: OSD buttons and LithoSphere controls now match
  Leaflet zoom controls (var(--color-a) bg, var(--color-f) text,
  var(--color-mmgis) hover, 30px size, 3px border-radius)
- Viewer settings sliders use var(--color-a3) instead of hardcoded
  #444444
- Az/el indicator stays at bottom center (exception per design)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Consistent modal theming + session security fix

Modal theming:
- All modals now share consistent styling: backdrop-filter blur, semi-transparent
  background via --color-a-rgb, 10px border-radius, header divider line, box-shadow
- Updated: loginModal, Help, ConfirmationModal, Settings, Hotkeys, About modals
- Tool panel backgrounds changed from opaque var(--color-k) to transparent so the
  ToolPanel's existing backdrop-filter effect shows through
- Legend tool header updated to match consistent 44px height with divider
- applyTheme.js now auto-derives --color-a-rgb from theme's --color-a hex value
- Modal service wrapper gets backdrop-filter: blur(12px)

Session security (Devin Review fix):
- Token re-auth now calls req.session.regenerate() with data preservation to
  prevent session fixation while maintaining multi-tab compatibility
- Token is rotated via crypto.randomBytes on every re-auth (was being reused)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI fixes: viewer settings, button sizing, menu contrast, coords, login header

1. Viewer OSD settings moved to top of button stack, panel opens downward
2. Overlay buttons consistent 30x30px (OSD line-height fix, home button)
3. Menu/icon contrast improved: Dropdown items and IconButtons use --color-a5
   (was --color-a3) with --color-f on hover for better dark theme legibility
4. CoordinatesDiv fixed to 30px height, pickLngLat button centered
5. Login modal now has a header bar with 'Log In' title and close X button;
   title toggles to 'Sign Up' when switching modes

Also reverts session regeneration for token re-auth (Devin Review feedback):
token-based re-auth now refreshes session data in-place without regeneration
or token rotation, preserving multi-tab compatibility.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI polish: nav popover, sep tools, compass, zoom controls, status indicator, toast

1. Description nav popover: added z-index:9000 so menu appears above map panels
2. Separated tools: default color changed from accent to --color-f; fixed CSS
   selector from .toolButtonSep to .toolSep to match actual class names
3. Compass + mapping scale shifted left by 30px for better positioning
4. Map zoom/home controls: use --color-f instead of accent --color-c to reduce
   visual prominence; hover still highlights with accent color
5. Status indicators (reload/ws disconnect/layer update) moved from Leaflet
   control to TopBar with soft pulsing fade animation and tooltip on hover
6. WebSocket retry toast: rounded corners, glass background with backdrop-filter,
   border-left accent for failure state instead of solid red background

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix 14 tool UI issues: headers, backgrounds, layout, functional bugs

Header alignment:
- LayersTool: align-items center on #filterLayers, gap between right icons
- InfoTool: align-items center on #infoToolHeader, 44px height
- ViewshedTool: align-items center, restructured header with left/right divs
- IsochroneTool: restructured flat header into nested mmgisToolHeader pattern
- ShadeTool: align-items center on #vstHeader children

Icon spacing:
- LayersTool: increased right margin to 28px + gap 2px
- ViewshedTool: #vstNew padding-right 30px (clear of close button)
- IsochroneTool: #iscNew padding-right 30px

Missing components:
- SitesTool: added Help import + help icon via mmgisToolHeader pattern
- AnimationTool: added full mmgisToolHeader with title and help icon

Background fixes:
- InfoTool: changed toolsContainer background from transparent to var(--color-a)
- DrawTool: changed toolsContainer background from transparent to var(--color-a)

Layout fixes:
- DrawTool: #drawToolContents top 81px, height calc(100%-81px), #drawToolNav margin-right 0
- MeasureTool: removed padding-left:0 override from mmgisToolHeader child selector

Functional fixes:
- InfoTool: updated jQuery selectors from #InfoTool to #toolButtonInfo (React toolbar IDs changed)
- CurtainTool: deferred OpenSeadragon init with requestAnimationFrame (React 18 async render)
- CurtainTool: curtainToolBar justify-content flex-end (icons at bottom)

Security:
- TopBar StatusIndicator: escape HTML in layer names to prevent XSS via addLayerQueue

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix mapToolBar pointer events, login padding, default tool, About modal order

1. mapToolBar: set pointer-events:none on both #mapToolBar and direct children
   so clicks pass through to the map; leaf elements still get auto via
   .childpointerevents rule
2. #loginModalBody: padding changed to 40px 0px 0px
3. Default tool: deferred click to requestAnimationFrame so React toolbar
   has rendered before getElementById runs
4. About modal: moved mainInfoModalCustom to right below mainInfoModalHero

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix tool headers to 40px, ViewshedTool subheader, AnimationTool header, InfoTool close btn, statusIndicator position

1. All tool panel headers: changed from 44px to exactly 40px height
   - Global .mmgisToolHeader and .mmgisToolTitle in mmgis.css
   - InfoTool.css, ViewshedTool.css, IsochroneTool.css, ShadeTool.css

2. LayersTool #filterLayers: height 40px, .right > div height unset,
   .right margin-right 30px, .right > div margin 0px 3px

3. ViewshedTool: restructured header — title+help in mmgisToolHeader row,
   vstToggleAll (left) and vstNew (right) on a new #vstSubHeader row below

4. AnimationTool: removed old #animationToolHeader CSS (padding 15px 20px,
   white background, 18px font), now uses standard mmgisToolHeader class.
   Fixed color from var(--color-a) (background) to var(--color-f) (text)

5. InfoTool close X: re-inject close button after use() rebuilds content
   via TC_.injectCloseButton() (toolsContainer.empty() was removing it)

6. StatusIndicator: moved to left of topBarTitle in JSX render order.
   Added align-items:center to #topBarMain for vertical alignment.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Remove title attr from StatusIndicator (conflicts with tippy tooltip)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TimeUI dropdown z-index above tool panel, reposition toasts to top-center

1. TimeUI dropdowns: added z-index 10000 to all dropy content ul elements
   so they render above the vertical tool panel (z-index 1400). Also set
   timeUIDock to position:relative with z-index 10000 and overflow:visible.

2. Toast notifications: repositioned #toast-container from bottom-right to
   top-center just below the topbar (top: 44px, centered with transform).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix StatusIndicator spacing, CurtainTool close btn, header title font consistency

1. StatusIndicator: use display:none/flex instead of opacity:0/1 so it
   takes no space when there's no active status indicator.

2. CurtainTool: added close X button at top of curtainToolBar (matching
   MeasureTool pattern) with flex spacer pushing other buttons to bottom.

3. Header title font consistency: InfoTool, ShadeTool, CurtainTool titles
   now match mmgisToolTitle standard (font-weight:600, padding-left:10px,
   height:40px for CurtainTool which was 34px).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Legend empty state, scalefactor position, sep-tool-header unbold

1. Legend: show 'No active layers with legends' when no legend items
   are present. Also fixed container height calc(100% - 40px).

2. Mapping Scale (.leaflet-control-scalefactor): shifted 10px right
   (left 26→36px) and 1px down (bottom 30→29px).

3. sep-tool-header span: font-weight changed from 600 to 400.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Guard Legend empty state message to only show when panel is active

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TimeUI dropdown covered by toolPanel: remove splitscreens stacking context

#splitscreens had z-index:1 which created a stacking context, confining
its children (including bottomFloatingBar at z-index:1500) to that context.
Since ToolPanel (z-index:1400) was a sibling outside splitscreens, it
painted above all splitscreens children regardless of their internal
z-index values.

Fix: change #splitscreens z-index from 1 to auto so it no longer creates
a stacking context. Now bottomFloatingBar (1500) participates in the
same stacking context as ToolPanel (1400), and 1500 > 1400 means the
TimeUI dropdown correctly paints above the tool panel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Migrate ~69 CursorInfo toast-like calls to proper Toast component

- Replace CursorInfo.update() toast-like calls with Toast.info/success/warning/error
- Preserve message colors: blue→info, green→success, yellow→warning, red→error
- Keep 12 legitimate cursor-following CursorInfo calls unchanged
- Files migrated: DrawTool.js, DrawTool_Files.js, DrawTool_FileModal.js,
  DrawTool_Templater.js, DrawTool_SetOperations.js, DrawTool_Drawing.js,
  DrawTool_Editing.js, DrawTool_Shapes.js, LayersTool.js, ShadeTool.js,
  chemistrychart.js
- Fix Devin Review: Change misleading 'Bad token' to 'Login failed' in users.js
- Normalize line endings (CRLF→LF) in affected files

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Toast.js missing from git, CoordinatesDiv z-index, Legend duplicate ID, topBar padding

- Add Toast.js to version control (was untracked, causing webpack module error)
- Bump CoordinatesDiv z-index from 20 to 1001 (was hidden behind splitscreens
  children after z-index:auto change)
- Fix Legend duplicate ID: separated tool icon was #LegendTool, same as content
  container div, causing empty message to appear in button instead of panel
- Add hasStatus class to #topBarMain when statusIndicator is active, setting
  #topBarTitleName padding-left to 0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Legend empty message: scope selector to content container via targetId

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Move Toast.js to design-system, fix --color-a3 text contrast, update AGENTS.md

- Move Toast.js from UserInterface_/components/Toast/ to design-system/components/Toast/
  (generic component belongs in design-system, not MMGIS-specific UserInterface_)
- Update all 11 Toast import paths to new location
- Bump --color-a3 in 5 dark themes to pass WCAG AA 4.5:1 contrast for text:
  Dark Default #747c81→#81888d, Dark Blue #64748b→#738399,
  Dark Warm #8b7a5e→#908064, Dark Mars #8a6a60→#98796f,
  Dark Midnight #606088→#7a7a9e
- Update AGENTS.md: document design-system/ vs UserInterface_/ distinction
  in project structure and Key Directories

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix topBarTitleName padding override: increase specificity and add !important

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix IdentifierTool deactivation: update icon ID reference in separateFromMMWebGIS

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Hide toolPanelDrag when no tool is open

- Add setToolPanelDragVisible(false) to closeActiveTool() (was only in makeTool toggle-off path)
- Also guard drag handle display on isOpen (toolPanelWidth > 0) as safety net

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add hover effect to MMGIS logo (opacity + brightness transition)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix MMGIS logo hover: keep full opacity, use subtle background highlight instead

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Selective tile fade: fade on pan/zoom, instant on refresh/reload

- Remove blanket _fadeAnimated toggle from toggleTimeUI (was killing fade
  for all tiles while TimeUI was open)
- Monkey-patch GridLayer.redraw, TileLayer.setUrl, and GridLayer._tileReady
  to suppress fade via a transient _suppressTileFade map flag
- Set _suppressTileFade in reloadTimeLayers for time-driven reloads
- Flag auto-clears after 300ms so pan/zoom tiles still get the nice fade
- Install pbf dependency (required by CesiumMVTLayer from #942 merge)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Per-layer fade control: time-enabled + shade/viewshed layers never fade

- Replace transient map-level _suppressTileFade with per-layer _noFade flag
- Patch GridLayer._tileReady to check _noFade on the layer instance
- Set _noFade on time-enabled tile layers and data/GL layers at creation
- Set _noFade on Shade and Viewshed tool GL layers
- Non-time-enabled base imagery tiles still fade normally on pan/zoom

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile UI improvements — move hamburger to right menu, show panel toggles, isMobile-driven toolbar layout, desktop-matching scalebar/compass

- Remove left hamburger menu (#topBarMenu), move BottomBar items into
  top-right kebab dropdown menu for both mobile and desktop
- Show panel toggles (Viewer/Map/Globe) and account/login UI in mobile
  topbar's #topBarRight
- Move toolbar horizontal layout CSS from @media breakpoints to
  UserInterfaceMobile_.css (loaded only when isMobile flag is true)
- Remove #mapTopBar @media rule from mmgis.css, add to mobile CSS
- Remove mobile-only simplified scalebar rendering; use full desktop
  scalebar with both large and small axes on all viewports
- Remove display:none on .leaflet-control-scalefactor in mobile CSS
- Remove #loginDiv display:none from mobile CSS (React overlay handles it)
- Simplify BottomBarReact container styles (no more absolute positioning)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260430 [version bump]

* fix: mobile toolbar 40px height, bottomFloatingBar flush, timeUI toggle, scalebar position, hide hotkeys

- Toolbar height 40px, toolButton width 40px, no border-bottom
- toolcontroller_incdiv: no padding-bottom, overflow-y hidden
- bottomFloatingBar: no border-radius, left/right/bottom = 0
- Add MobileTimeUIToggle button on far right of toolbar
- Hide Keyboard Shortcuts from kebab menu on mobile
- Fix scalebar positioning (remove top:48px override in UserInterfaceBridge)
- Set mobileTopSize/topSize to 40 (splitscreens top = 40px, not 50px)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile topBar padding-left 34px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: update MobileCoordButton topBar paddingLeft from 80px to 34px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: MobileTimeUIToggle — inline toggle logic, float right, hide from settings on mobile

- Replace broken Coordinates.toggleTimeUI() call with direct jQuery/store toggle
- Float time button right in toolbar
- Hide Time UI toggle from settings modal on mobile (toolbar has it)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: push scalebar/compass/scale up 40px on mobile, keep #timeUI in DOM

- BottomElementPositioner: position mapToolBar, leaflet-bottom-left/right
  40px above bottom on mobile (above toolbar)
- Stop removing #timeUI from DOM on mobile so MobileTimeUIToggle works

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — only show endtime, always expanded

- Hide #mmgisTimeUIStartWrapper and StartWrapperFake on mobile via CSS
- Force expanded state (addClass expanded + show) when toggling TimeUI on
- CSS ensures #timeUI.active always shows expanded content on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI opens in tool panel with header, end time, expanded rows

- MobileTimeUIToggle now opens/closes the tool panel via ToolController_
- Closes any active tool before showing TimeUI
- Forces expanded state when opening
- CSS hides start time inputs, positions expanded content properly
- Overrides absolute positioning of expanded content for tool panel flow

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: rewrite separated tools system from jQuery to React components

- Add separatedToolsList/activeSeparatedTools state to Zustand uiStore
- Rewrite SeparatedTools.jsx with glassmorphism panels, CSS Module styling
- Replace SepToolsContainer (setInterval hack) with SepToolButton/SepToolsSection
- Remove ~170 lines of jQuery DOM construction from ToolController_.js
- Fix hardcoded rgba(26,26,27,0.88) to theme-aware var(--color-a-rgb)
- Remove separated tool entries from themeApplier.js
- Remove separated tool overrides from FloatingElements.css
- Move Legend CSS overrides from Toolbar.module.css to SeparatedTools.module.css
- Remove jQuery active-state manipulation from IdentifierTool.js
- Add store sync in Map_.js displayOnStart logic
- Preserve all DOM IDs for backward compatibility (mmgisAPI, tool make())

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260501 [version bump]

* fix: TimeUI mobile checks — use Zustand store instead of L_.UserInterface_

L_.UserInterface_ is null when TimeUI.init() runs (TimeControl.init is called
before L_.link sets UserInterface_). All 16 isMobile checks now read from
useUIStore.getState().isMobile which is set at startup.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: move displayOnStart logic from Map_.js to ToolController_.finalizeTools()

- Map_ no longer references specific tools (LegendTool)
- displayOnStart is now handled generically for all separated tools
- Added DOM element polling (tryMake) to handle React render timing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* revert: remove all TimeUI-related mobile changes

Reverts TimeUI.js and BottomBar.js to development base.
Restores #timeUI DOM removal in UserInterfaceBridge.fina().
Removes MobileTimeUIToggle component from Toolbar.jsx.
Removes TimeUI mobile CSS overrides from UserInterfaceMobile_.css.
Non-TimeUI refinements (toolbar height, scalebar positioning, etc.) preserved.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* simplify: remove DOM polling, use simple setTimeout(0) for auto-open

LegendTool handles its own content lifecycle via subscribeOnLayerToggle.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: mobile TimeUI — fix isMobile detection, staging container, toolbar toggle

- TimeUI.js: import useUIStore and replace all 16 L_.UserInterface_?.isMobile
  checks with useUIStore.getState().isMobile (L_.UserInterface_ is null when
  TimeUI.init() runs, so mobile conditionals were dead code)
- TimeUI.js: stage mobile #timeUI in hidden #timeUIMobileStaging instead of
  placing directly in #tools (which gets cleared by other tools)
- UserInterfaceBridge.js: stop removing #timeUI from DOM on mobile
- Toolbar.jsx: add MobileTimeUIToggle that moves #timeUI between staging and
  #tools, opens/closes tool panel via ToolController_
- BottomBar.js: hide TimeUI toggle from settings modal on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: rescue #timeUI back to staging when another tool opens

Subscribe to activeToolName changes — when a tool becomes active while
TimeUI is showing, move #timeUI back to #timeUIMobileStaging before
the new tool's make() clears #tools.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove separatedTool/justification config toggles, fix review issues

- Remove separatedTool checkbox and justification dropdown from Legend
  and Identifier config.json (these are always separated, not configurable)
- Remove justification property/code from LegendTool.js, IdentifierTool.js
- Simplify Globe_.js separated tool count (no justification filter)
- Remove justification from Reference-Mission config blueprint
- Update LegendTool help docs and Legend.md documentation
- Add --color-a-rgb fallback (29,31,32) in SeparatedTools.module.css
- Add display:none !important to .panelIdentifier to prevent 12px gap
- Update e2e test comment

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: circular import in TimeUI.js, toolbar/bottomFloatingBar position sync

- TimeUI.js: replace top-level useUIStore import with lazy _getUIStore()
  accessor to avoid 'Cannot access useUIStore before initialization'
  circular import error at _remakeTimeSlider
- SplitScreens.jsx: skip #timeUI reparenting observer on mobile (mobile
  uses MobileTimeUIToggle to manage #timeUI placement in #tools)
- BottomElementPositioner.jsx: unify mobile transition to 0.3s (matches
  toolsWrapper and toolbar), guard pxIsTools against undefined
- Toolbar.jsx: align toolbar transition to 0.3s ease-out

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* LegendTool fix empty message

* chore: remove separated tools offset logic from Globe_.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: skip _makeHistogram on mobile (no timeline slider, timestamps unset)

_makeHistogram renders inside the timeline slider which doesn't exist
on mobile. Without it, _timelineStartTimestamp is NaN, causing
'Invalid time value' RangeError at toISOString().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — populate expanded rows, fix Invalid date, fix panel height

- TimeUI.js attachEvents: use _initialStart/_initialEnd on mobile (same
  as desktop) instead of L_.TimeControl_ which isn't set yet at init time.
  Fixes 'Invalid date' in start/end time inputs.
- TimeUI.js fina: set expanded=true on mobile and call _populateExpandedRows()
  so year/month/day/hour rows actually render. Removed position:absolute and
  pointer-events:none overrides.
- Toolbar.jsx: set tool panel height to 217px (TimeUI.height) instead of
  45% viewport — matches actual TimeUI content height.
- UserInterfaceMobile_.css: expanded content flows naturally (position:relative),
  hide start time inputs, allow overflow scroll, flex-wrap topbar.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI justify-content center, restore toolbar border-bottom

- Add justify-content: center to #mmgisTimeUIMain on mobile
- Remove border-bottom: none override so toolbar keeps its default border

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI overflow hidden, scalebar/compass fixed at 40px offset

- #timeUI overflow-y: hidden (was auto, causing 2px scroll)
- Scalebar/compass/map controls stay at fixed 40px offset (above toolbar)
  regardless of tool panel state — no longer shift up by pxIsTools

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Implement multi-tier knowledge architecture

- Restructure AGENTS.md from 745 lines to 106 lines (Tier 1: essential context)
- Create knowledge/ directory with 30+ wiki-style documentation files (Tier 2: deep knowledge)
- Create knowledge/reference/ with 8 detailed reference files (Tier 3: lookup material)
- Move AI-GETTING-STARTED.md and AI-DEVELOPMENT.md to knowledge/
- Update all file references in .specify/templates and blueprints
- Create knowledge/README.md as the full knowledge base index
- Create knowledge/reference/README.md as reference material index

Three-tier knowledge discovery system:
  Tier 1: AGENTS.md (~106 lines) - scannable in <2 minutes
  Tier 2: knowledge/*.md - deep knowledge on architecture, tools, APIs, DB, infra
  Tier 3: knowledge/reference/*.md - coding conventions, API reference, troubleshooting

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: mobile toolbar active button style matches desktop, fix icon alignment

- All mobile toolbar buttons (ToolButton, MobileCoordButton, MobileTimeUIToggle)
  now use display:flex with align-items/justify-content center for proper
  vertical icon centering
- MobileCoordButton: changed 'active' class to 'toolButtonActive' to match
  the global CSS active style (color-mmgis + color-i background)
- Removed inline color overrides so CSS .toolButtonActive takes effect

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add Devin knowledge notes from past MMGIS sessions

Include curated lessons learned from past Devin sessions:
- CI/CD: ignore build-arm64/amd64 failures, focus on required checks
- Child sessions: no separate PRs when consolidating
- ENV triple-update rule (.env, sample.env, ENVs.md)
- Error handling: use logger with infrastructure_error for fatal startup errors
- Path traversal security: stay within /Missions, handle subpath serving
- Database initialization architecture and migration patterns
- API authentication behavior across AUTH modes
- Auto-generated MMGIS concept index

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar active button style, icon alignment, tool deactivation

- Active toolbar buttons get desktop-matching margin (1px 0) and
  border-radius (8px) via .toolButton.toolButtonActive CSS rule
- Removed line-height: 40px from .toolButton (flex centering handles
  vertical alignment, line-height was pushing icons up)
- MobileCoordButton now watches activeToolName store and deactivates
  when another tool opens (fixes coords staying active)
- MobileTimeUIToggle sets activeToolName='MobileTimeUI' when opening
  so coords/other buttons can detect it and deactivate
- MobileTimeUIToggle clears activeToolName when closing
- Both custom buttons skip self-deactivation via name check

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix cross-references: convert backtick refs to markdown links, add Devin knowledge notes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon height 40px, button margins for active padding

- #toolbar .toolButton i: height 40px fixes icon vertical alignment
- #toolbar .toolButton: margin 0 2px gives spacing between buttons
- #toolbar .toolButton.toolButtonActive: margin 1px 2px so active
  background has visual padding around the icon

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename knowledge/ to .knowledge/ for consistency with .specify/ convention

Dot-prefix signals agent infrastructure (not source code), consistent with
.specify/, .github/, .vscode/ conventions. All cross-references updated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon line-height 40px, active button padding via height

- Coord and TimeUI button <i> icons get line-height: 40px
- Active buttons: height 34px (vs 40px toolbar) creates visual padding
  around the active background, centered by flex align-items
- Buttons get margin: 0 1px for horizontal spacing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix broken cross-reference: 06.2 -> 06.1-configure-rest-api.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: close active tool + cancel deferred cleanup in MobileCoordButton/TimeUI

- MobileCoordButton: call closeActiveTool() before opening, destroy
  _pendingCloseTool if set, increment _closeSeq to cancel deferred
  tools.innerHTML clear
- MobileTimeUIToggle: same _pendingCloseTool + _closeSeq fix after
  closeActiveTool() to prevent 420ms deferred cleanup from wiping
  #timeUI after it's placed in #tools
- Removed redundant closeActiveTool() from MobileCoordButton close path
  (was being called after destroy, not needed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: active mobile toolbar buttons 34x34px (square)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Drastically compress .knowledge/ — keep only unique agent content

Remove 33 wiki files that duplicate docs/pages/ content.
Remove 9 reference/ files derivable from source code.
Keep only 5 files (down from 46):
  - AI-GETTING-STARTED.md (agent setup walkthrough)
  - AI-DEVELOPMENT.md (spec-kit workflow)
  - conventions-and-gotchas.md (naming, code style, common issues)
  - 12-devin-knowledge-notes.md (CI, auth, DB init, security gotchas)
  - README.md (index pointing to docs/pages/ for everything else)

Principle: don't duplicate docs/ — only keep what's uniquely agent-optimized.
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename to knowledge-notes.md, remove Devin branding and fork-specific CI section

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: hide mmgis-map-logo on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore Database Safety Rules for AI Agents section in AGENTS.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: shift compass and map scale 6px to the right (both mobile and desktop)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add back Important Instructions, code pattern templates, and detailed project structure

- Important Instructions in AGENTS.md: MCP tools, hot-reload, Reference Mission
- .knowledge/code-patterns.md: full directory tree with key directory annotations,
  plus copy-paste templates for Express routes, Sequelize models, Tool plugins,
  and WebSocket handlers

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Update project structure trees to reflect current filesystem

Add missing directories: tests/, .knowledge/, .specify/, .github/, views/,
pri…
…974)

* Re-apply 36 UI improvements on development's React architecture

Adapted all 36 UI tasks from PR #47 to work with development's full
React component architecture (Toolbar, Splitter, BottomBarReact, etc.)
instead of PR #45's null-stub + jQuery layout.

Layout & Positioning (Tasks 1, 4, 5, 11, 16, 17, 20, 27):
- BottomElementPositioner: immediate positioning with toolPanelWidth offset
- Scalebar/compass: 12px permanent right push + tool panel width
- Toolbar: flex-direction column layout

Controls & Navigation (Tasks 2, 9, 10):
- Map zoom: Home button resets to configured initial view
- BottomBarReact: reduced to About + Copy Link only
- TopBar: kebab menu with Screenshot, Fullscreen, Hotkeys, Settings

Tool System (Tasks 3, 6, 7, 13):
- Tool buttons: CSS classes (.toolButtonActive) instead of inline styles
- Tool headers: standardized with mmgisToolHeader/mmgisToolTitle classes
- MeasureTool: close button at top, reset/download at bottom

Visual Polish (Tasks 8, 14, 18, 25, 26, 29, 30, 32, 34, 36):
- Splitter: invisible by default, accent color on hover
- Compass: smooth left transition
- Coordinates: background, separator between mouse/pick
- mmgislogo: border-right matching topbar border-bottom
- toolsWrapper: no border in bottomFloatingBar

TimeUI & Settings (Tasks 15, 23, 24, 28):
- toggleTimeUI button removed from coordinates
- TimeUI toggle added to Settings modal
- Popover positioning uses window.innerHeight - bcr.top

Modals & Config (Tasks 12, 19, 21):
- About modal: MMGIS logo, version, attributions, markdown content
- Modal close: proper click handler with stopPropagation
- Attributions: removed from DOM, collected for About modal

Panel Sync (Task 31):
- TopBar panel toggles sync with Zustand store via subscribe

Dependencies & Config:
- Added dompurify for markdown sanitization
- Added aboutModalContent to config template and Reference Mission
- Added 9 help markdown files for tools

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add theming system (6 presets), floating tool panel, CSS variable support

- Port design-system: themes.js (6 presets), applyTheme.js, themeApplier.js, useTheme.js
- Wire theme into Zustand store (themeName + setTheme action)
- Wire theme into UserInterfaceBridge init/fina and Stylize.js
- Add theme dropdown to configure page (look.theme field)
- Update mmgisUI.css to use CSS variables for theme support
- Make tool panel float over map instead of docking/pushing
- Hide splitter drag handles by default
- Fix Coordinates.css margin for removed TimeUI button

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix useTheme.js for React 16 compatibility (useState/useEffect instead of useSyncExternalStore)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix layout to match PR #47: floating tool panel, bottom bar, toolbar, topbar

- ToolPanel: 12px inset margins from toolbar/topbar, border-radius 10px,
  backdrop-filter blur(20px), opacity transition on open/close
- Toolbar: box-shadow, z-index 101, 34x34 tool buttons with border-radius 8px,
  starts below topbar (top: topSize), Toolbar.css imported
- TopBar: solid background + border-bottom via themeApplier, box-shadow via CSS
- Bottom floating bar: new BottomFloatingBar wraps toolsWrapper + timeUIDock
  inside #splitscreens with 12px margins, backdrop blur, border-radius
- TimeUI reparented into floating bar via MutationObserver
- BottomElementPositioner: recalculated offsets based on floating bar height
- Separated tools: offset by toolPanelWidth + 24px when tool panel is open
- Splitter arrow buttons hidden (panel toggles in TopBar replace them)
- FloatingElements.css: backdrop blur for TimeUI, coordinates, compass, zoom
- SplitScreens.css, BottomBarReact.css, ToolPanel.css, Toolbar.css added

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TopBar: stays full-width when tool panel opens (matches PR #47)

- Remove toolPanelWidth/toolsWrapperRawWidth reactive styles that shifted
  TopBar right when tool panel opened
- TopBar now always uses left:0, width:100%, padding-left:40px (from CSS)
- Tool panel floats underneath TopBar (z-index 2005 > 1400)
- Add theme colors for toggle buttons, user avatar via CSS variables
- Remove redundant z-index override in TopBar.css

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix ToolPanel drag handle offset: use panelLeftOffset instead of hardcoded 10

The drag handle's left position uses panelLeftOffset (52px on desktop)
but the drag calculation was using 10 as offset, causing width to
inflate by 42px after each drag-resize.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TopBar solid background and splitscreens positioning below TopBar

- Remove minimalist(true) call from fina() — matches PR #47 which also
  removed it. Splitscreens now correctly starts at top:40px (topSize)
  instead of top:0px which caused map to render under the TopBar.
- Add background: var(--color-a) and border-bottom: 1px solid var(--color-a1)
  to #topBar CSS so it's solid from initial render (themeApplier.js
  overrides on theme change).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Port separated tools and Legend changes from PR #47

- SeparatedTools.jsx returns null — separated tool rendering moved to
  ToolController_.js jQuery DOM construction (matches PR #47)
- ToolController_.init() now creates floating glassmorphism panels for
  separated tools (Legend, Identifier) with header/close buttons
- Separated tool buttons appear in toolbar below a divider
- LegendTool.js header updated to use mmgisToolHeader/mmgisToolTitle
  classes with Help integration
- tab-ui-config.json: removed Default Tool section, renamed Colors to
  Advanced Color Overrides
- Maker.js: removed defaulttooldropdown component type

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Show MMGIS logo by default (was hidden behind minimalist() call)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add tool panel close button, separated tool improvements

- Add injectCloseButton() to ToolController_ with X button in upper right
  of tool panel (both vertical and horizontal tools)
- Call injectCloseButton() after tool.make() in makeTool()
- Add tippy tooltips to separated tool buttons (Legend, Identifier)
- Add divider background color using CSS variable
- Add isMobile check to skip separated tools on mobile (matching PR #47)
- Order Legend button last in separated tools section (matching PR #47)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix bottom bar layout for all 4 tool/TimeUI combinations

- Remove direct CSS manipulation from TimeUI._updateBottomUIHeight(),
  delegate to centralized BottomElementPositioner via Zustand store
- Add React close button to BottomFloatingBar for horizontal tools
  (replaces jQuery injection that was wiped by React re-renders)
- Skip jQuery close button injection for horizontal tools since React
  BottomFloatingBar now handles it
- Add leaflet-bottom-left positioning to BottomElementPositioner
- All 4 combos verified: no tool/collapsed, no tool/expanded,
  measure/collapsed, measure/expanded

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix 13 UI/layout issues in PR #48

1. Remove redundant loginDiv and loginoutButton (hidden via useEffect)
2. mmgislogo border-right now uses var(--color-a1) matching topbar border-bottom
3. About button moved below Copy Link in toolbar bottom
4. Separated tools (Legend, Identifier) now appear in toolbar via SepToolsContainer
5. Map zoom/home controls use solid background matching topbar/toolbar
6. bottomFloatingBar z-index raised to 1500 (above toolpanel 1400)
7. Compass offset reduced from +38 to +8 (closer to scalebar)
8. mmgisToolHeader now flex row so MeasureTool [Title ? ... undo reset] layout works
9. Toolbar z-index raised to 2006 (above topbar 2005, prevents box-shadow overlap)
10. Light theme text contrast: CoordinatesDiv, topBarMain, TimeUI buttons, DrawTool
11. TimeUI timeline colors now update with theme changes via themeApplier
12. DrawTool tabs visible: #drawToolNotLoggedIn now starts at top:81px below nav
13. Bottom floating bar left offset accounts for tool panel width

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix 16 UI issues (#14-29)

14. Bottom bar no longer pushed by vertical tool panel
15. LayersTool header restored (reverted mmgisToolHeader flex, fix MeasureTool inline)
16. Separated Legend panel pushed right when vertical tool opens
17. Legend appears before Identifier in toolbar
18. loginDiv/loginoutButton hidden via CSS !important
19. Horizontal tool panel width fixed (always 100% of bottom bar)
20. Identifier tool button no longer auto-activates on load
21. Splitters use theme accent color (--color-mmgis)
22. Vertical toolpanel drag edge styled like splitters
23. Horizontal toolpanel drag handle (6px accent bar at top)
24. Compass positioning simplified (flows with leaflet container)
25. Light theme text: Coordinates.css uses var(--color-f), themeApplier strengthened
26. Globe toggle: Globe_.init() instead of non-existent lazyInit()
27. Separated Legend panel theme: headers and content text use theme colors
28. InfoTool null guard on destroy (MMGISInterface may be null)
29. Splitter drag no longer offset by tool panel width (panel floats now)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix computeToolsSplitMoveResult min clamp to preserve splitterSize/4 fallback

The test expects splitterSize/4 as minimum when toolNativeHeight is not set.
Use Math.max(toolNativeHeight, splitterSize/4) so both constraints apply.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix 8 UI issues (#30-37)

30. Remove duplicate close button (React BottomFloatingBar one removed; tool's own stays)
31. Splitters/drag handles transparent by default, accent color on hover/drag only
32. Horizontal tool splitter follows mouse (disable CSS transition during drag via isDraggingSplitter flag)
33. Legend pushed right by extra 12px gap when vertical tool panel open
34. Compass (leaflet-bottom-left) pushed right when vertical tool panel opens
35. TimeUI mode dropdown z-index raised above tool panel
36. Glassy backdrop-filter on floating elements (CoordinatesDiv container transparent)
37. CoordinatesDiv: height 30px, bottom/right 12px permanent offset

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* React 18 upgrade + Base UI adoption + CSS Modules migration

- Upgrade react and react-dom from ^16.13.1 to ^18.2.0
- Upgrade react-app-polyfill to ^3.0.0, react-resize-detector to ^9.1.0
- Migrate entry point (src/index.js) to createRoot API
- Fix ReactDOM.render in CurtainTool.js and PDFViewer.js
- Fix ReactDOM.createPortal import in UserInterfaceLayout.jsx
- Install @base-ui-components/react@1.0.0-rc.0

Design system wrapper components (src/design-system/components/):
- Button.jsx + Button.module.css (primary/secondary/ghost variants)
- IconButton.jsx + IconButton.module.css
- Dropdown.jsx + Dropdown.module.css (wraps Base UI Menu)
- Toggle.jsx + Toggle.module.css (panel toggle groups)
- Modal.jsx + Modal.module.css (wraps Base UI Dialog)
- Tooltip.jsx + Tooltip.module.css (wraps Base UI Tooltip)

CSS Modules migration for shell components:
- TopBar: Rewritten with Toggle and Dropdown components, CSS Modules
- ToolPanel: CSS Modules, replaced hardcoded rgba with color-mix()
- SplitScreens: CSS Modules, replaced hardcoded rgba with theme vars
- Splitter: Added Splitter.module.css
- Toolbar: Migrated to CSS Modules
- BottomBarReact: Migrated to CSS Modules
- UserInterfaceLayout: Migrated to CSS Modules

themeApplier.js cleanup:
- Removed imperative selectors for #topBar, #toolPanel, #bottomFloatingBar,
  #topBarTitleName, #topBarRight icons, #topBarMain (now CSS Modules)
- Kept selectors for jQuery/Leaflet/TimeUI elements

webpack.config.js: Added namedExport: false for css-loader v7 compatibility

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Upgrade react-chartjs-2 to ^4.3.1 for React 18 peer dep compatibility

react-chartjs-2@3.3.0 only supported react ^16.8.0 || ^17.0.0.
v4.3.1 adds react ^18.0.0 support while keeping chart.js ^3.5.0 compat.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix #topBarInfo ID conflict: rename About button to #bottomBarAbout

The About button was reusing the #topBarInfo ID, which caused:
1. UserInterfaceBridge.js visibility logic hiding it unless look.info/infourl set
2. Stylize.js jQuery click handler opening infourl simultaneously with About modal

Renamed to #bottomBarAbout so it doesn't conflict with the legacy #topBarInfo
jQuery handler. The original #topBarInfo info-URL behavior remains untouched.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Address Devin Review findings: infourl, theme override, dead code

- Add look.infourl as a row in the About modal so it remains accessible
- Remove dead jQuery click handler for #topBarInfo in Stylize.js
- Remove duplicate setTheme() in UserInterfaceBridge.fina() that was
  clobbering individual color overrides already applied by Stylize.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix config visibility toggles for kebab menu items + clean up dead handlers

- Add lookConfig to Zustand store, set from UserInterfaceBridge.fina()
- TopBar: conditionally render Screenshot, Fullscreen, Settings, Copy Link
  dropdown items based on lookConfig (screenshot, fullscreen, settings, copylink)
- BottomBarReact: conditionally render Copy Link button based on lookConfig
- Remove dead jQuery click handlers for #topBarHelp and #topBarInfo in Stylize.js
- Remove dead DOM getElementById calls in UserInterfaceBridge for elements that
  no longer exist (topBarScreenshot, topBarFullscreen, bottomBarSettings, etc.)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix toggleTimeUI() reading state from removed #toggleTimeUI element

The #toggleTimeUI button was removed from the Coordinates markup but
toggleTimeUI() still read active state from it. Since the element doesn't
exist, $('#toggleTimeUI').hasClass('active') always returns false, causing:
- Map_.map._fadeAnimated always set to false (fade animations never re-enabled)
- L_._onTimeUIToggleSubscriptions always told TimeUI is being turned ON

Fix: read state from #timeUI (which exists and tracks the active class)
instead of #toggleTimeUI.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Improve Base UI adoption and reduce :global() overuse

- TopBar: use IconButton for sign-in/kebab buttons, use Dropdown for user card
  popup (replaces hand-rolled outside-click + manual popup with Base UI Menu
  for keyboard nav, focus trap, accessibility)
- BottomBarReact: use IconButton + Tooltip instead of raw <i> + tippy.js
- Toolbar: use Tooltip instead of tippy.js for tool button tooltips
- Reduce :global() from 33 to 22 instances — all remaining are justified by
  external jQuery/imperative code references:
  - SplitScreens: convert #viewerScreen/#globeScreen to scoped classes
  - Toolbar: convert #toolcontroller_incdiv to scoped class
  - Splitter: convert .splitterV to scoped class
  - UserInterfaceLayout: consolidate TopBar styles into TopBar.module.css
  - BottomBarReact: fully scoped (IconButton handles all button styling)

Design system usage: 5 of 6 wrappers now imported across 3 files
(Toggle, Dropdown, IconButton, Tooltip — only Button/Modal unused,
which is appropriate since there are no standalone button/modal cases
in the main site shell).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix: remove stale setShowUserCard call in handleLogout

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Migrate Ancillary UI components from jQuery to React/native DOM

- Modal.js: React-based with imperative API bridge (Modal.set/remove)
  so existing jQuery callers (BottomBar, DrawTool, AnimationTool, etc.)
  work without changes. Uses React state + createRoot for rendering.

- ConfirmationModal.js: Removed jQuery dependency, uses native DOM
  event listeners with the React Modal backend.

- Help.js: Removed jQuery dependency, uses native fetch() + DOM
  event listeners instead of $.get() and $().on().

- ContextMenu.js: Removed jQuery dependency entirely. Uses native
  DOM APIs (createElement, addEventListener, querySelectorAll) for
  building and managing the right-click context menu.

- Compass.js: Removed jQuery dependency. Uses native DOM APIs
  (getElementById, querySelector) for compass element creation
  and bearing updates.

- MapLogo.js: Removed jQuery dependency. Uses native DOM APIs
  for logo element creation and Leaflet container insertion.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Migrate Ancillary components to true React + Base UI

- Modal.js: React component using Base UI Dialog with shared state store,
  imperative Modal.set()/remove() API preserved for backwards compatibility.
  Accepts both HTML strings (legacy callers) and React elements (new callers).

- ConfirmationModal.js: React JSX component using design-system Button for
  Yes/No actions, rendered through Modal service. Same prompt() API.

- Help.js: React JSX component using native fetch + showdown for markdown
  rendering, rendered through Modal service. Same getComponent/finalize API.

- ContextMenu.js: Full React component with createRoot, JSX menu items with
  proper event handlers. Same init()/remove() API.

- Compass.js: React component rendered via createRoot into Leaflet
  bottom-left container. SVG compass with bearing rotation on map events.

- MapLogo.js: React component rendered via createRoot into Leaflet
  bottom-right container. Configurable size and link support.

All components use CSS Modules. Old plain CSS files removed.

Also fixes:
- About modal now respects look.help/look.info config flags
- Cleaned up dead #toggleTimeUI references in Coordinates.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix modal, tooltip, measure, and context menu bugs

- Modal: Fix About modal not opening due to async createRoot in React 18.
  ModalHost now initializes from shared state and syncs on mount.
  Blur management moved to ModalHost (React state-driven), fixing
  persistent blur after modal close.

- Tooltip/Dropdown: Use render prop on Trigger to avoid nesting
  <button> inside <button> (IconButton is already a button element).

- MeasureTool: Migrate from deprecated ReactDOM.render to createRoot.
  Register Chart.js scales via Chart.register(...registerables) to fix
  'linear is not a registered scale' error with react-chartjs-2 v4.

- ContextMenu: Fix crash when right-clicking on LithoSphere scene
  (native events lack originalEvent). Store element and handler refs
  for proper cleanup, preventing listener leaks.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Modal: remove close button, fix blur persistence, add fade animation

- Remove the Dialog.Close button (curved bottom-left border-radius)
- Drop Base UI Dialog wrapper entirely — it was fighting with the
  imperative Modal.set/remove API. Now uses simple divs with CSS
  transitions, matching the original jQuery modal behavior exactly.
- Blur management is now purely imperative via _applyBlur() called
  synchronously in set() and remove(). Removed async useEffect approach.
- Add 500ms CSS opacity fade-in/fade-out transition matching original.
- Closing state: Modal.remove() marks modal as closing (triggers
  opacity 0 transition), then removes from DOM after 500ms.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename Ancillary React components to .jsx, fix modal blur via Zustand store, fix IconButton forwardRef, add Help.finalize calls

- Rename Modal, ConfirmationModal, Help, ContextMenu, Compass, MapLogo from .js to .jsx
- Route modal blur through Zustand modalBlurCount instead of imperative DOM manipulation
- Remove conflicting jQuery blur animation in Layers_.js
- Wrap IconButton with React.forwardRef to fix Tooltip/Menu trigger warnings
- Add Help.finalize() calls to ChemistryTool, DrawTool, IsochroneTool
- Change aboutModalContent config type from 'markdown' to 'textarea'

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert aboutModalContent type back to 'markdown' — Maker.js already handles it

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Help.jsx: check res.ok on fetch, sanitize HTML with DOMPurify

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix ContextMenu WKT null guard, fix Modal blur timing during fade-out

- Add null check for feature in handleActionClick WKT placeholder handling
- Delay blur removal until after 500ms fade-out completes (blur stays in sync with backdrop opacity)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Reorganize folder structure: dissolve Ancillary, nest components

- Dissolve src/essence/Ancillary/ entirely
  - UI components → UserInterface_/components/ (Modal, ConfirmationModal, Help,
    ContextMenu, Compass, MapLogo, CursorInfo, Attributions, Login)
  - Layout chrome → UserInterface_/components/ (Description, Coordinates, Search,
    ScaleBar, ScaleBox)
  - Pure services → essence/services/ (DataShaders, LocalFilterer, QueryURL, Sprites)
  - Stylize.js → design-system/ (theme bridge alongside themeApplier)
  - Delete unused Swap.js

- Nest all components into own folders (ComponentName/ComponentName.ext pattern):
  - UserInterface_/components/: TopBar/, Toolbar/, ToolPanel/, SplitScreens/,
    Splitter/, BottomBar/, BottomElementPositioner/, Layout/, Panels/
  - design-system/components/: Button/, IconButton/, Dropdown/, Toggle/, Modal/,
    Tooltip/

- Update ~70+ import paths across codebase
- Build verified locally

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Modal.set() race condition and themeApplier CSS variable override issue

- Modal.set() onAddCallback: Replace 50ms setTimeout with MutationObserver
  that waits for the modal element to appear in DOM before firing callback.
  Prevents silent jQuery binding failures on slower devices.

- themeApplier: Use Proxy to read computed CSS custom properties (set by
  Stylize.js per-mission overrides) instead of hardcoded theme object values.
  Stylize.js now calls refreshThemeDOM() after setting CSS variables so
  inline styles reflect mission-specific color overrides.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Sync toggleTimeUI DOM state to Zustand store

toggleTimeUI() now calls setTimeUIActive() and setTimeUIExpanded()
so BottomFloatingBar visibility and BottomElementPositioner offsets
reflect actual TimeUI state.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore #toggleTimeUI element in Coordinates markup

The element was accidentally dropped during the folder restructure move.
TimeUI.js and DrawTool.js check $('#toggleTimeUI').hasClass('active')
to gate histogram rendering and time-filter toggle visibility.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix #toggleTimeUI: restore click handler, tippy, active class; remove redundant jQuery positioning

Restored pieces lost during folder restructure:
- Click handler in init() and off handler in remove()
- Tippy tooltip for the time toggle button
- display:none when time is not enabled
- $('#toggleTimeUI').toggleClass('active') so TimeUI.js can check it
- $('#CoordinatesDiv > #toggleTimeUI').remove() on mobile

Removed jQuery CSS positioning from toggleTimeUI() since
BottomElementPositioner now reactively handles all bottom-anchored
element offsets via the Zustand store.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix CurtainTool.destroy() using undefined ReactDOM.unmountComponentAtNode

Use the stored _reactRoot.unmount() instead, matching the React 18
createRoot pattern already used in make().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix tooltips, scale indicator position, modal blur, help close button

Tooltips:
- Reduce Base UI Tooltip delay from 600ms (default) to 200ms
- Restyle tooltip popup to match tippy blue theme (var(--color-c2))
- Add Tooltip wrappers to TopBar panel toggles (Viewer/Map/Globe)
- Wrap Toggle with forwardRef so Tooltip render prop can attach ref
- Remove title attrs that conflicted with custom tooltips

Scale indicator:
- Remove scalefactor-specific positioning from BottomElementPositioner
  (it moves naturally with .leaflet-bottom.leaflet-left container)
- Position scalefactor to the left of compass at same bottom level

Modal blur:
- Call _applyBlur() immediately when marking modal as closing
  so blur clears during fade-out instead of persisting 500ms

Help modal:
- Add close (X) button in title bar matching other modal patterns

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Remove dead CSS: delete tools.css, clean ~600 lines from mmgisUI.css and mmgis.css

- Delete tools.css entirely (both selectors #CurtainToolList and
  .searchToolSelect are unreferenced anywhere in the codebase)
- Remove from mmgisUI.css: .mmgisRadioBar3/4/Vertical (140 lines),
  .mmgispureselect (104 lines), blink/condemned_blink_effect (38 lines),
  .slidecontainer/.slider (41 lines), .ar_slider (91 lines),
  .verticalSlider (91 lines), .mmgisMultirange_elev (19 lines),
  .ui-corner-all/bottom/right/br (9 lines)
- Remove from mmgis.css: #nodeenv, empty #topBar{}, #topBarInfo,
  #topBarHelp, #topBarFullscreen, #toggleUI, #logoGoBack
- Keep #topBarLink (used in BottomBarReact.jsx), #webgl-error-message
  (used by vendored THREE.js)
- All selectors verified with repo-wide grep before removal

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI fixes: tooltips, splitter hover, mobile toolbar, color schemes

- Replace Base UI Tooltip with simple React portal tooltip (200ms delay,
  tippy-matching style) — fixes missing tooltips for toolbar/topbar/bottom buttons
- Add cursor + hover highlight to vertical splitters (was missing because
  module CSS didn't inherit global .splitterV styles)
- Add hover highlight to tool panel drag handle
- Remove mdi-drag-vertical icon from tool panel drag
- Add mobile toolbar horizontal layout via @media query overrides
- Add 4 new color schemes: High Contrast (a11y), Dark Mars, Dark Midnight,
  Light Warm (total: 10 themes)
- Previous fixes also included in working tree:
  - timeUI border moved to toolsWrapper border-bottom (conditional)
  - #toggleTimeUI button removed entirely
  - CoordinatesDiv: vertical centering, unified background, 12px right offset
  - barBottom padding-bottom: 8px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix assignment operator used instead of comparison in TimeControl.fina()

Pre-existing bug: `TimeControl.enabled = true` was assigning instead of
comparing. Changed to `TimeControl.enabled === true`.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restructure Configure page UI tab: add all themes, Custom mode, enableWhenField

- Added all 10 theme presets to dropdown (was missing Dark Mars, Dark Midnight,
  Light Warm, High Contrast)
- Added 'Custom' option: skips preset theme, uses only color picker values
- Moved Theming section directly under Rebranding
- Nested 'Custom Color Options' under Theming with subdescription
- Added enableWhenField support to Maker.js: disables color pickers unless
  theme is set to Custom
- Renamed color options with clearer names and improved descriptions:
  Primary → Surface Color, Secondary → Deep Background Color,
  Tertiary → Text Color, Body → Page Body Color, Highlight → Feature Highlight
- Stylize.js: skip setTheme() when theme is 'Custom'
- Rebuilt configure page

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert tooltips to tippy.js, fix dropdown menus, redesign About modal

1. Tooltip: Replaced custom React portal tooltip with tippy.js wrapper.
   Uses the existing tippy.js dependency and 'blue' theme for consistency.

2. Dropdown: Replaced Base UI Menu with native portal dropdown.
   Base UI's nested Menu.Trigger + BaseButton composition was swallowing
   click events, breaking userAvatar and menuBtn menus. New implementation
   uses simple state + createPortal with proper outside-click dismissal.

3. About modal: Professional redesign with centered MMGIS ASCII art header,
   proper GitHub SVG logo link, clean metadata section, centered link
   buttons, attributions section, and NASA-AMMOS footer.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore .mmgisHelpButton base styles lost during Help.css module migration

The global .mmgisHelpButton styles (yellow color, compact 18x18px sizing,
0.7 opacity) were removed when Help.css was converted to Help.module.css.
Since Help.getComponent() emits raw HTML strings for jQuery-rendered tool
headers, it cannot use CSS Module scoped classes. Restored the base styles
in mmgis.css alongside the related .mmgisToolHelpBtn definition.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix session logout regression, About modal refinements, High Contrast theme, Stylize.js, Default Tool config

- Login: skip session.regenerate() for token-based re-auth (useToken:true)
  so reloading the main page no longer invalidates the configure page session
- About modal: replace ASCII art with mmgis.png logo, rename Attributions to
  Map Layer Attributions, remove footer logo, link NASA-AMMOS to ammos.nasa.gov
- High Contrast theme: change accent from #ffff00 to #ffd700 (gold) for better
  contrast ratios against dark backgrounds
- Stylize.js: color overrides only apply when theme is Custom or unset,
  preventing preset themes from being clobbered by stale config values
- Restore Default Tool config section in tab-ui-config.json (accidentally
  removed during Theming section reorganization)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore defaulttooldropdown case handler in Maker.js

The case was accidentally removed during the Configure page UI tab
restructure (d7f96c50). Without it, the Default Tool dropdown in the
Configure page rendered as nothing despite the config referencing it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* High Contrast tooltip text, panel toggle styling, scale position swap

- High Contrast theme: tooltips now use black text on yellow background
  via --color-c2-text variable (white for all other themes)
- About modal links use var(--color-f) for consistent theme text color
- Panel toggle buttons: 11px uppercase with 600 weight for better
  visibility
- Mapping scale button moved to bottom-right of compass (was top-left)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Reposition Viewer and Globe panel buttons to top-right

- Viewer: dropdown selector at top-right edge, OSD buttons stacked
  vertically below it; settings panel opens to the left
- Globe: home, exaggerate, observe, walk, link controls moved from
  TopLeft to TopRight corner via addControl 4th arg
- Style consistency: OSD buttons and LithoSphere controls now match
  Leaflet zoom controls (var(--color-a) bg, var(--color-f) text,
  var(--color-mmgis) hover, 30px size, 3px border-radius)
- Viewer settings sliders use var(--color-a3) instead of hardcoded
  #444444
- Az/el indicator stays at bottom center (exception per design)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Consistent modal theming + session security fix

Modal theming:
- All modals now share consistent styling: backdrop-filter blur, semi-transparent
  background via --color-a-rgb, 10px border-radius, header divider line, box-shadow
- Updated: loginModal, Help, ConfirmationModal, Settings, Hotkeys, About modals
- Tool panel backgrounds changed from opaque var(--color-k) to transparent so the
  ToolPanel's existing backdrop-filter effect shows through
- Legend tool header updated to match consistent 44px height with divider
- applyTheme.js now auto-derives --color-a-rgb from theme's --color-a hex value
- Modal service wrapper gets backdrop-filter: blur(12px)

Session security (Devin Review fix):
- Token re-auth now calls req.session.regenerate() with data preservation to
  prevent session fixation while maintaining multi-tab compatibility
- Token is rotated via crypto.randomBytes on every re-auth (was being reused)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI fixes: viewer settings, button sizing, menu contrast, coords, login header

1. Viewer OSD settings moved to top of button stack, panel opens downward
2. Overlay buttons consistent 30x30px (OSD line-height fix, home button)
3. Menu/icon contrast improved: Dropdown items and IconButtons use --color-a5
   (was --color-a3) with --color-f on hover for better dark theme legibility
4. CoordinatesDiv fixed to 30px height, pickLngLat button centered
5. Login modal now has a header bar with 'Log In' title and close X button;
   title toggles to 'Sign Up' when switching modes

Also reverts session regeneration for token re-auth (Devin Review feedback):
token-based re-auth now refreshes session data in-place without regeneration
or token rotation, preserving multi-tab compatibility.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI polish: nav popover, sep tools, compass, zoom controls, status indicator, toast

1. Description nav popover: added z-index:9000 so menu appears above map panels
2. Separated tools: default color changed from accent to --color-f; fixed CSS
   selector from .toolButtonSep to .toolSep to match actual class names
3. Compass + mapping scale shifted left by 30px for better positioning
4. Map zoom/home controls: use --color-f instead of accent --color-c to reduce
   visual prominence; hover still highlights with accent color
5. Status indicators (reload/ws disconnect/layer update) moved from Leaflet
   control to TopBar with soft pulsing fade animation and tooltip on hover
6. WebSocket retry toast: rounded corners, glass background with backdrop-filter,
   border-left accent for failure state instead of solid red background

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix 14 tool UI issues: headers, backgrounds, layout, functional bugs

Header alignment:
- LayersTool: align-items center on #filterLayers, gap between right icons
- InfoTool: align-items center on #infoToolHeader, 44px height
- ViewshedTool: align-items center, restructured header with left/right divs
- IsochroneTool: restructured flat header into nested mmgisToolHeader pattern
- ShadeTool: align-items center on #vstHeader children

Icon spacing:
- LayersTool: increased right margin to 28px + gap 2px
- ViewshedTool: #vstNew padding-right 30px (clear of close button)
- IsochroneTool: #iscNew padding-right 30px

Missing components:
- SitesTool: added Help import + help icon via mmgisToolHeader pattern
- AnimationTool: added full mmgisToolHeader with title and help icon

Background fixes:
- InfoTool: changed toolsContainer background from transparent to var(--color-a)
- DrawTool: changed toolsContainer background from transparent to var(--color-a)

Layout fixes:
- DrawTool: #drawToolContents top 81px, height calc(100%-81px), #drawToolNav margin-right 0
- MeasureTool: removed padding-left:0 override from mmgisToolHeader child selector

Functional fixes:
- InfoTool: updated jQuery selectors from #InfoTool to #toolButtonInfo (React toolbar IDs changed)
- CurtainTool: deferred OpenSeadragon init with requestAnimationFrame (React 18 async render)
- CurtainTool: curtainToolBar justify-content flex-end (icons at bottom)

Security:
- TopBar StatusIndicator: escape HTML in layer names to prevent XSS via addLayerQueue

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix mapToolBar pointer events, login padding, default tool, About modal order

1. mapToolBar: set pointer-events:none on both #mapToolBar and direct children
   so clicks pass through to the map; leaf elements still get auto via
   .childpointerevents rule
2. #loginModalBody: padding changed to 40px 0px 0px
3. Default tool: deferred click to requestAnimationFrame so React toolbar
   has rendered before getElementById runs
4. About modal: moved mainInfoModalCustom to right below mainInfoModalHero

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix tool headers to 40px, ViewshedTool subheader, AnimationTool header, InfoTool close btn, statusIndicator position

1. All tool panel headers: changed from 44px to exactly 40px height
   - Global .mmgisToolHeader and .mmgisToolTitle in mmgis.css
   - InfoTool.css, ViewshedTool.css, IsochroneTool.css, ShadeTool.css

2. LayersTool #filterLayers: height 40px, .right > div height unset,
   .right margin-right 30px, .right > div margin 0px 3px

3. ViewshedTool: restructured header — title+help in mmgisToolHeader row,
   vstToggleAll (left) and vstNew (right) on a new #vstSubHeader row below

4. AnimationTool: removed old #animationToolHeader CSS (padding 15px 20px,
   white background, 18px font), now uses standard mmgisToolHeader class.
   Fixed color from var(--color-a) (background) to var(--color-f) (text)

5. InfoTool close X: re-inject close button after use() rebuilds content
   via TC_.injectCloseButton() (toolsContainer.empty() was removing it)

6. StatusIndicator: moved to left of topBarTitle in JSX render order.
   Added align-items:center to #topBarMain for vertical alignment.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Remove title attr from StatusIndicator (conflicts with tippy tooltip)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TimeUI dropdown z-index above tool panel, reposition toasts to top-center

1. TimeUI dropdowns: added z-index 10000 to all dropy content ul elements
   so they render above the vertical tool panel (z-index 1400). Also set
   timeUIDock to position:relative with z-index 10000 and overflow:visible.

2. Toast notifications: repositioned #toast-container from bottom-right to
   top-center just below the topbar (top: 44px, centered with transform).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix StatusIndicator spacing, CurtainTool close btn, header title font consistency

1. StatusIndicator: use display:none/flex instead of opacity:0/1 so it
   takes no space when there's no active status indicator.

2. CurtainTool: added close X button at top of curtainToolBar (matching
   MeasureTool pattern) with flex spacer pushing other buttons to bottom.

3. Header title font consistency: InfoTool, ShadeTool, CurtainTool titles
   now match mmgisToolTitle standard (font-weight:600, padding-left:10px,
   height:40px for CurtainTool which was 34px).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Legend empty state, scalefactor position, sep-tool-header unbold

1. Legend: show 'No active layers with legends' when no legend items
   are present. Also fixed container height calc(100% - 40px).

2. Mapping Scale (.leaflet-control-scalefactor): shifted 10px right
   (left 26→36px) and 1px down (bottom 30→29px).

3. sep-tool-header span: font-weight changed from 600 to 400.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Guard Legend empty state message to only show when panel is active

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TimeUI dropdown covered by toolPanel: remove splitscreens stacking context

#splitscreens had z-index:1 which created a stacking context, confining
its children (including bottomFloatingBar at z-index:1500) to that context.
Since ToolPanel (z-index:1400) was a sibling outside splitscreens, it
painted above all splitscreens children regardless of their internal
z-index values.

Fix: change #splitscreens z-index from 1 to auto so it no longer creates
a stacking context. Now bottomFloatingBar (1500) participates in the
same stacking context as ToolPanel (1400), and 1500 > 1400 means the
TimeUI dropdown correctly paints above the tool panel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Migrate ~69 CursorInfo toast-like calls to proper Toast component

- Replace CursorInfo.update() toast-like calls with Toast.info/success/warning/error
- Preserve message colors: blue→info, green→success, yellow→warning, red→error
- Keep 12 legitimate cursor-following CursorInfo calls unchanged
- Files migrated: DrawTool.js, DrawTool_Files.js, DrawTool_FileModal.js,
  DrawTool_Templater.js, DrawTool_SetOperations.js, DrawTool_Drawing.js,
  DrawTool_Editing.js, DrawTool_Shapes.js, LayersTool.js, ShadeTool.js,
  chemistrychart.js
- Fix Devin Review: Change misleading 'Bad token' to 'Login failed' in users.js
- Normalize line endings (CRLF→LF) in affected files

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Toast.js missing from git, CoordinatesDiv z-index, Legend duplicate ID, topBar padding

- Add Toast.js to version control (was untracked, causing webpack module error)
- Bump CoordinatesDiv z-index from 20 to 1001 (was hidden behind splitscreens
  children after z-index:auto change)
- Fix Legend duplicate ID: separated tool icon was #LegendTool, same as content
  container div, causing empty message to appear in button instead of panel
- Add hasStatus class to #topBarMain when statusIndicator is active, setting
  #topBarTitleName padding-left to 0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Legend empty message: scope selector to content container via targetId

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Move Toast.js to design-system, fix --color-a3 text contrast, update AGENTS.md

- Move Toast.js from UserInterface_/components/Toast/ to design-system/components/Toast/
  (generic component belongs in design-system, not MMGIS-specific UserInterface_)
- Update all 11 Toast import paths to new location
- Bump --color-a3 in 5 dark themes to pass WCAG AA 4.5:1 contrast for text:
  Dark Default #747c81→#81888d, Dark Blue #64748b→#738399,
  Dark Warm #8b7a5e→#908064, Dark Mars #8a6a60→#98796f,
  Dark Midnight #606088→#7a7a9e
- Update AGENTS.md: document design-system/ vs UserInterface_/ distinction
  in project structure and Key Directories

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix topBarTitleName padding override: increase specificity and add !important

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix IdentifierTool deactivation: update icon ID reference in separateFromMMWebGIS

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Hide toolPanelDrag when no tool is open

- Add setToolPanelDragVisible(false) to closeActiveTool() (was only in makeTool toggle-off path)
- Also guard drag handle display on isOpen (toolPanelWidth > 0) as safety net

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add hover effect to MMGIS logo (opacity + brightness transition)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix MMGIS logo hover: keep full opacity, use subtle background highlight instead

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Selective tile fade: fade on pan/zoom, instant on refresh/reload

- Remove blanket _fadeAnimated toggle from toggleTimeUI (was killing fade
  for all tiles while TimeUI was open)
- Monkey-patch GridLayer.redraw, TileLayer.setUrl, and GridLayer._tileReady
  to suppress fade via a transient _suppressTileFade map flag
- Set _suppressTileFade in reloadTimeLayers for time-driven reloads
- Flag auto-clears after 300ms so pan/zoom tiles still get the nice fade
- Install pbf dependency (required by CesiumMVTLayer from #942 merge)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Per-layer fade control: time-enabled + shade/viewshed layers never fade

- Replace transient map-level _suppressTileFade with per-layer _noFade flag
- Patch GridLayer._tileReady to check _noFade on the layer instance
- Set _noFade on time-enabled tile layers and data/GL layers at creation
- Set _noFade on Shade and Viewshed tool GL layers
- Non-time-enabled base imagery tiles still fade normally on pan/zoom

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile UI improvements — move hamburger to right menu, show panel toggles, isMobile-driven toolbar layout, desktop-matching scalebar/compass

- Remove left hamburger menu (#topBarMenu), move BottomBar items into
  top-right kebab dropdown menu for both mobile and desktop
- Show panel toggles (Viewer/Map/Globe) and account/login UI in mobile
  topbar's #topBarRight
- Move toolbar horizontal layout CSS from @media breakpoints to
  UserInterfaceMobile_.css (loaded only when isMobile flag is true)
- Remove #mapTopBar @media rule from mmgis.css, add to mobile CSS
- Remove mobile-only simplified scalebar rendering; use full desktop
  scalebar with both large and small axes on all viewports
- Remove display:none on .leaflet-control-scalefactor in mobile CSS
- Remove #loginDiv display:none from mobile CSS (React overlay handles it)
- Simplify BottomBarReact container styles (no more absolute positioning)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260430 [version bump]

* fix: mobile toolbar 40px height, bottomFloatingBar flush, timeUI toggle, scalebar position, hide hotkeys

- Toolbar height 40px, toolButton width 40px, no border-bottom
- toolcontroller_incdiv: no padding-bottom, overflow-y hidden
- bottomFloatingBar: no border-radius, left/right/bottom = 0
- Add MobileTimeUIToggle button on far right of toolbar
- Hide Keyboard Shortcuts from kebab menu on mobile
- Fix scalebar positioning (remove top:48px override in UserInterfaceBridge)
- Set mobileTopSize/topSize to 40 (splitscreens top = 40px, not 50px)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile topBar padding-left 34px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: update MobileCoordButton topBar paddingLeft from 80px to 34px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: MobileTimeUIToggle — inline toggle logic, float right, hide from settings on mobile

- Replace broken Coordinates.toggleTimeUI() call with direct jQuery/store toggle
- Float time button right in toolbar
- Hide Time UI toggle from settings modal on mobile (toolbar has it)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: push scalebar/compass/scale up 40px on mobile, keep #timeUI in DOM

- BottomElementPositioner: position mapToolBar, leaflet-bottom-left/right
  40px above bottom on mobile (above toolbar)
- Stop removing #timeUI from DOM on mobile so MobileTimeUIToggle works

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — only show endtime, always expanded

- Hide #mmgisTimeUIStartWrapper and StartWrapperFake on mobile via CSS
- Force expanded state (addClass expanded + show) when toggling TimeUI on
- CSS ensures #timeUI.active always shows expanded content on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI opens in tool panel with header, end time, expanded rows

- MobileTimeUIToggle now opens/closes the tool panel via ToolController_
- Closes any active tool before showing TimeUI
- Forces expanded state when opening
- CSS hides start time inputs, positions expanded content properly
- Overrides absolute positioning of expanded content for tool panel flow

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: rewrite separated tools system from jQuery to React components

- Add separatedToolsList/activeSeparatedTools state to Zustand uiStore
- Rewrite SeparatedTools.jsx with glassmorphism panels, CSS Module styling
- Replace SepToolsContainer (setInterval hack) with SepToolButton/SepToolsSection
- Remove ~170 lines of jQuery DOM construction from ToolController_.js
- Fix hardcoded rgba(26,26,27,0.88) to theme-aware var(--color-a-rgb)
- Remove separated tool entries from themeApplier.js
- Remove separated tool overrides from FloatingElements.css
- Move Legend CSS overrides from Toolbar.module.css to SeparatedTools.module.css
- Remove jQuery active-state manipulation from IdentifierTool.js
- Add store sync in Map_.js displayOnStart logic
- Preserve all DOM IDs for backward compatibility (mmgisAPI, tool make())

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260501 [version bump]

* fix: TimeUI mobile checks — use Zustand store instead of L_.UserInterface_

L_.UserInterface_ is null when TimeUI.init() runs (TimeControl.init is called
before L_.link sets UserInterface_). All 16 isMobile checks now read from
useUIStore.getState().isMobile which is set at startup.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: move displayOnStart logic from Map_.js to ToolController_.finalizeTools()

- Map_ no longer references specific tools (LegendTool)
- displayOnStart is now handled generically for all separated tools
- Added DOM element polling (tryMake) to handle React render timing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* revert: remove all TimeUI-related mobile changes

Reverts TimeUI.js and BottomBar.js to development base.
Restores #timeUI DOM removal in UserInterfaceBridge.fina().
Removes MobileTimeUIToggle component from Toolbar.jsx.
Removes TimeUI mobile CSS overrides from UserInterfaceMobile_.css.
Non-TimeUI refinements (toolbar height, scalebar positioning, etc.) preserved.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* simplify: remove DOM polling, use simple setTimeout(0) for auto-open

LegendTool handles its own content lifecycle via subscribeOnLayerToggle.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: mobile TimeUI — fix isMobile detection, staging container, toolbar toggle

- TimeUI.js: import useUIStore and replace all 16 L_.UserInterface_?.isMobile
  checks with useUIStore.getState().isMobile (L_.UserInterface_ is null when
  TimeUI.init() runs, so mobile conditionals were dead code)
- TimeUI.js: stage mobile #timeUI in hidden #timeUIMobileStaging instead of
  placing directly in #tools (which gets cleared by other tools)
- UserInterfaceBridge.js: stop removing #timeUI from DOM on mobile
- Toolbar.jsx: add MobileTimeUIToggle that moves #timeUI between staging and
  #tools, opens/closes tool panel via ToolController_
- BottomBar.js: hide TimeUI toggle from settings modal on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: rescue #timeUI back to staging when another tool opens

Subscribe to activeToolName changes — when a tool becomes active while
TimeUI is showing, move #timeUI back to #timeUIMobileStaging before
the new tool's make() clears #tools.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove separatedTool/justification config toggles, fix review issues

- Remove separatedTool checkbox and justification dropdown from Legend
  and Identifier config.json (these are always separated, not configurable)
- Remove justification property/code from LegendTool.js, IdentifierTool.js
- Simplify Globe_.js separated tool count (no justification filter)
- Remove justification from Reference-Mission config blueprint
- Update LegendTool help docs and Legend.md documentation
- Add --color-a-rgb fallback (29,31,32) in SeparatedTools.module.css
- Add display:none !important to .panelIdentifier to prevent 12px gap
- Update e2e test comment

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: circular import in TimeUI.js, toolbar/bottomFloatingBar position sync

- TimeUI.js: replace top-level useUIStore import with lazy _getUIStore()
  accessor to avoid 'Cannot access useUIStore before initialization'
  circular import error at _remakeTimeSlider
- SplitScreens.jsx: skip #timeUI reparenting observer on mobile (mobile
  uses MobileTimeUIToggle to manage #timeUI placement in #tools)
- BottomElementPositioner.jsx: unify mobile transition to 0.3s (matches
  toolsWrapper and toolbar), guard pxIsTools against undefined
- Toolbar.jsx: align toolbar transition to 0.3s ease-out

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* LegendTool fix empty message

* chore: remove separated tools offset logic from Globe_.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: skip _makeHistogram on mobile (no timeline slider, timestamps unset)

_makeHistogram renders inside the timeline slider which doesn't exist
on mobile. Without it, _timelineStartTimestamp is NaN, causing
'Invalid time value' RangeError at toISOString().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — populate expanded rows, fix Invalid date, fix panel height

- TimeUI.js attachEvents: use _initialStart/_initialEnd on mobile (same
  as desktop) instead of L_.TimeControl_ which isn't set yet at init time.
  Fixes 'Invalid date' in start/end time inputs.
- TimeUI.js fina: set expanded=true on mobile and call _populateExpandedRows()
  so year/month/day/hour rows actually render. Removed position:absolute and
  pointer-events:none overrides.
- Toolbar.jsx: set tool panel height to 217px (TimeUI.height) instead of
  45% viewport — matches actual TimeUI content height.
- UserInterfaceMobile_.css: expanded content flows naturally (position:relative),
  hide start time inputs, allow overflow scroll, flex-wrap topbar.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI justify-content center, restore toolbar border-bottom

- Add justify-content: center to #mmgisTimeUIMain on mobile
- Remove border-bottom: none override so toolbar keeps its default border

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI overflow hidden, scalebar/compass fixed at 40px offset

- #timeUI overflow-y: hidden (was auto, causing 2px scroll)
- Scalebar/compass/map controls stay at fixed 40px offset (above toolbar)
  regardless of tool panel state — no longer shift up by pxIsTools

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Implement multi-tier knowledge architecture

- Restructure AGENTS.md from 745 lines to 106 lines (Tier 1: essential context)
- Create knowledge/ directory with 30+ wiki-style documentation files (Tier 2: deep knowledge)
- Create knowledge/reference/ with 8 detailed reference files (Tier 3: lookup material)
- Move AI-GETTING-STARTED.md and AI-DEVELOPMENT.md to knowledge/
- Update all file references in .specify/templates and blueprints
- Create knowledge/README.md as the full knowledge base index
- Create knowledge/reference/README.md as reference material index

Three-tier knowledge discovery system:
  Tier 1: AGENTS.md (~106 lines) - scannable in <2 minutes
  Tier 2: knowledge/*.md - deep knowledge on architecture, tools, APIs, DB, infra
  Tier 3: knowledge/reference/*.md - coding conventions, API reference, troubleshooting

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: mobile toolbar active button style matches desktop, fix icon alignment

- All mobile toolbar buttons (ToolButton, MobileCoordButton, MobileTimeUIToggle)
  now use display:flex with align-items/justify-content center for proper
  vertical icon centering
- MobileCoordButton: changed 'active' class to 'toolButtonActive' to match
  the global CSS active style (color-mmgis + color-i background)
- Removed inline color overrides so CSS .toolButtonActive takes effect

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add Devin knowledge notes from past MMGIS sessions

Include curated lessons learned from past Devin sessions:
- CI/CD: ignore build-arm64/amd64 failures, focus on required checks
- Child sessions: no separate PRs when consolidating
- ENV triple-update rule (.env, sample.env, ENVs.md)
- Error handling: use logger with infrastructure_error for fatal startup errors
- Path traversal security: stay within /Missions, handle subpath serving
- Database initialization architecture and migration patterns
- API authentication behavior across AUTH modes
- Auto-generated MMGIS concept index

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar active button style, icon alignment, tool deactivation

- Active toolbar buttons get desktop-matching margin (1px 0) and
  border-radius (8px) via .toolButton.toolButtonActive CSS rule
- Removed line-height: 40px from .toolButton (flex centering handles
  vertical alignment, line-height was pushing icons up)
- MobileCoordButton now watches activeToolName store and deactivates
  when another tool opens (fixes coords staying active)
- MobileTimeUIToggle sets activeToolName='MobileTimeUI' when opening
  so coords/other buttons can detect it and deactivate
- MobileTimeUIToggle clears activeToolName when closing
- Both custom buttons skip self-deactivation via name check

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix cross-references: convert backtick refs to markdown links, add Devin knowledge notes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon height 40px, button margins for active padding

- #toolbar .toolButton i: height 40px fixes icon vertical alignment
- #toolbar .toolButton: margin 0 2px gives spacing between buttons
- #toolbar .toolButton.toolButtonActive: margin 1px 2px so active
  background has visual padding around the icon

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename knowledge/ to .knowledge/ for consistency with .specify/ convention

Dot-prefix signals agent infrastructure (not source code), consistent with
.specify/, .github/, .vscode/ conventions. All cross-references updated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon line-height 40px, active button padding via height

- Coord and TimeUI button <i> icons get line-height: 40px
- Active buttons: height 34px (vs 40px toolbar) creates visual padding
  around the active background, centered by flex align-items
- Buttons get margin: 0 1px for horizontal spacing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix broken cross-reference: 06.2 -> 06.1-configure-rest-api.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: close active tool + cancel deferred cleanup in MobileCoordButton/TimeUI

- MobileCoordButton: call closeActiveTool() before opening, destroy
  _pendingCloseTool if set, increment _closeSeq to cancel deferred
  tools.innerHTML clear
- MobileTimeUIToggle: same _pendingCloseTool + _closeSeq fix after
  closeActiveTool() to prevent 420ms deferred cleanup from wiping
  #timeUI after it's placed in #tools
- Removed redundant closeActiveTool() from MobileCoordButton close path
  (was being called after destroy, not needed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: active mobile toolbar buttons 34x34px (square)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Drastically compress .knowledge/ — keep only unique agent content

Remove 33 wiki files that duplicate docs/pages/ content.
Remove 9 reference/ files derivable from source code.
Keep only 5 files (down from 46):
  - AI-GETTING-STARTED.md (agent setup walkthrough)
  - AI-DEVELOPMENT.md (spec-kit workflow)
  - conventions-and-gotchas.md (naming, code style, common issues)
  - 12-devin-knowledge-notes.md (CI, auth, DB init, security gotchas)
  - README.md (index pointing to docs/pages/ for everything else)

Principle: don't duplicate docs/ — only keep what's uniquely agent-optimized.
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename to knowledge-notes.md, remove Devin branding and fork-specific CI section

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: hide mmgis-map-logo on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore Database Safety Rules for AI Agents section in AGENTS.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: shift compass and map scale 6px to the right (both mobile and desktop)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add back Important Instructions, code pattern templates, and detailed project structure

- Important Instructions in AGENTS.md: MCP tools, hot-reload, Reference Mission
- .knowledge/code-patterns.md: full directory tree with key directory annotations,
  plus copy-paste templates for Express routes, Sequelize models, Tool plugins,
  and WebSocket handlers

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Update project structure trees to reflect current filesystem

Add missing directories: tests/, .knowledge/, .specify/, .github/, views/,
private/, spice/, build/, examples/, scripts/middleware.js.
Both abbreviated (AGENTS.md) and detailed (.knowledge/code-patterns.md) trees
now match the actual repo layout.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.30-20260501 [version bump]

* Add Layers_.js to project structure (key singleton L_)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix project structure: correct API layout, frontend modules, code templates

API/Backend/ uses feature-domain modules (Draw/, Users/, Config/, etc.)
with setup.js + routes/ + models/ per feature — not APIs/ or Databases/.
Frontend essence/ has Components/, Helpers/, LandingPage/, mmgisAPI/,
services/ — not Ancillary/. Basics/ includes all singletons (Globe_,
Formulae_, ToolController_, Viewer_, ComponentController_, Test_).
Code templates updated to match actual patterns (setup.js, module.exports).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove test infrastructure (Test_ module, testModules, DrawTool.test)

- Delete src/essence/Basics/Test_/ directory
- Delete src/essence/Tools/Draw/DrawTool.test.js
- Remove Test_ import and Shift+T keydown handler from essence.js
- Remove tests key from Draw tool config.json
- Remove testModules generation logic from API/updateTools.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.31-20260501 [version bump]

* style: move Cesium link button to top-right and match Leaflet zoom button styling

- Change control container from top-left to top-right positioning
- Update button size from 26px to 30px to match Leaflet zoom controls
- Use CSS variables (--color-a, --color-f, --color-mmgis) instead of hardcoded colors
- Add border-radius and box-shadow matching Leaflet control appearance
- Update hover/inactive states to use themed colors

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: anchor map logo to viewport instead of Leaflet map panel

- Change MapLogo parent from .leaflet-bottom.leaflet-right to #main-container
- Switch CSS position from absolute to fixed for viewport anchoring
- Add explicit bottom-offset positioning in BottomElementPositioner (desktop)
- Add explicit bottom-offset positioning in BottomElementPositioner (mobile)
- Logo stays at viewport right edge regardless of open side panels
- Retains smooth bottom offset transitions when bottom bar appears

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: remove references to deleted test infrastructure (Test_, DrawTool.test)

- Remove Test_/ from project structure in .knowledge/code-patterns.md
- Remove DrawTool.test.js references from specs/006 spec, plan, and tasks
- Remove Draw Tool Testing section from tasks.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.32-20260501 [version bump]

* fix: append logo to document.body to avoid filter containing block

#main-container has a CSS filter property which creates a new containing
block per the CSS spec, causing position:fixed to behave like absolute.
Appending to document.body ensures true viewport-fixed positioning.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: prevent mobile topBarTitleName text wrapping by replacing max-width with white-space: nowrap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.33-20260501 [version bump]

* chore: bump version to 5.0.0 and update changelog

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(ui): move Screenshot/Fullscreen to BottomBar, About to TopBar kebab

TopBar kebab menu now contains only Keyboard Shortcuts, Settings, and About
(About now shows on both desktop and mobile).

BottomBarReact now renders Screenshot, Fullscreen, and Copy Link buttons
(top to bottom) following the same IconButton + Tooltip pattern. The
About button has been removed from BottomBarReact.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(mobile): enforce exclusive panel toggling on mobile in TopBar

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* style: reposition LithoSphere globe controls to match Leaflet/Cesium theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(topbar): hide Viewer/Globe toggles based on configured panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): reorder buttons (Copy Link, Screenshot, Fullscreen) and unify size

Reorder the BottomBarReact buttons…
* Improve Base UI adoption and reduce :global() overuse

- TopBar: use IconButton for sign-in/kebab buttons, use Dropdown for user card
  popup (replaces hand-rolled outside-click + manual popup with Base UI Menu
  for keyboard nav, focus trap, accessibility)
- BottomBarReact: use IconButton + Tooltip instead of raw <i> + tippy.js
- Toolbar: use Tooltip instead of tippy.js for tool button tooltips
- Reduce :global() from 33 to 22 instances — all remaining are justified by
  external jQuery/imperative code references:
  - SplitScreens: convert #viewerScreen/#globeScreen to scoped classes
  - Toolbar: convert #toolcontroller_incdiv to scoped class
  - Splitter: convert .splitterV to scoped class
  - UserInterfaceLayout: consolidate TopBar styles into TopBar.module.css
  - BottomBarReact: fully scoped (IconButton handles all button styling)

Design system usage: 5 of 6 wrappers now imported across 3 files
(Toggle, Dropdown, IconButton, Tooltip — only Button/Modal unused,
which is appropriate since there are no standalone button/modal cases
in the main site shell).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix: remove stale setShowUserCard call in handleLogout

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Migrate Ancillary UI components from jQuery to React/native DOM

- Modal.js: React-based with imperative API bridge (Modal.set/remove)
  so existing jQuery callers (BottomBar, DrawTool, AnimationTool, etc.)
  work without changes. Uses React state + createRoot for rendering.

- ConfirmationModal.js: Removed jQuery dependency, uses native DOM
  event listeners with the React Modal backend.

- Help.js: Removed jQuery dependency, uses native fetch() + DOM
  event listeners instead of $.get() and $().on().

- ContextMenu.js: Removed jQuery dependency entirely. Uses native
  DOM APIs (createElement, addEventListener, querySelectorAll) for
  building and managing the right-click context menu.

- Compass.js: Removed jQuery dependency. Uses native DOM APIs
  (getElementById, querySelector) for compass element creation
  and bearing updates.

- MapLogo.js: Removed jQuery dependency. Uses native DOM APIs
  for logo element creation and Leaflet container insertion.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Migrate Ancillary components to true React + Base UI

- Modal.js: React component using Base UI Dialog with shared state store,
  imperative Modal.set()/remove() API preserved for backwards compatibility.
  Accepts both HTML strings (legacy callers) and React elements (new callers).

- ConfirmationModal.js: React JSX component using design-system Button for
  Yes/No actions, rendered through Modal service. Same prompt() API.

- Help.js: React JSX component using native fetch + showdown for markdown
  rendering, rendered through Modal service. Same getComponent/finalize API.

- ContextMenu.js: Full React component with createRoot, JSX menu items with
  proper event handlers. Same init()/remove() API.

- Compass.js: React component rendered via createRoot into Leaflet
  bottom-left container. SVG compass with bearing rotation on map events.

- MapLogo.js: React component rendered via createRoot into Leaflet
  bottom-right container. Configurable size and link support.

All components use CSS Modules. Old plain CSS files removed.

Also fixes:
- About modal now respects look.help/look.info config flags
- Cleaned up dead #toggleTimeUI references in Coordinates.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix modal, tooltip, measure, and context menu bugs

- Modal: Fix About modal not opening due to async createRoot in React 18.
  ModalHost now initializes from shared state and syncs on mount.
  Blur management moved to ModalHost (React state-driven), fixing
  persistent blur after modal close.

- Tooltip/Dropdown: Use render prop on Trigger to avoid nesting
  <button> inside <button> (IconButton is already a button element).

- MeasureTool: Migrate from deprecated ReactDOM.render to createRoot.
  Register Chart.js scales via Chart.register(...registerables) to fix
  'linear is not a registered scale' error with react-chartjs-2 v4.

- ContextMenu: Fix crash when right-clicking on LithoSphere scene
  (native events lack originalEvent). Store element and handler refs
  for proper cleanup, preventing listener leaks.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Modal: remove close button, fix blur persistence, add fade animation

- Remove the Dialog.Close button (curved bottom-left border-radius)
- Drop Base UI Dialog wrapper entirely — it was fighting with the
  imperative Modal.set/remove API. Now uses simple divs with CSS
  transitions, matching the original jQuery modal behavior exactly.
- Blur management is now purely imperative via _applyBlur() called
  synchronously in set() and remove(). Removed async useEffect approach.
- Add 500ms CSS opacity fade-in/fade-out transition matching original.
- Closing state: Modal.remove() marks modal as closing (triggers
  opacity 0 transition), then removes from DOM after 500ms.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename Ancillary React components to .jsx, fix modal blur via Zustand store, fix IconButton forwardRef, add Help.finalize calls

- Rename Modal, ConfirmationModal, Help, ContextMenu, Compass, MapLogo from .js to .jsx
- Route modal blur through Zustand modalBlurCount instead of imperative DOM manipulation
- Remove conflicting jQuery blur animation in Layers_.js
- Wrap IconButton with React.forwardRef to fix Tooltip/Menu trigger warnings
- Add Help.finalize() calls to ChemistryTool, DrawTool, IsochroneTool
- Change aboutModalContent config type from 'markdown' to 'textarea'

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert aboutModalContent type back to 'markdown' — Maker.js already handles it

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Help.jsx: check res.ok on fetch, sanitize HTML with DOMPurify

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix ContextMenu WKT null guard, fix Modal blur timing during fade-out

- Add null check for feature in handleActionClick WKT placeholder handling
- Delay blur removal until after 500ms fade-out completes (blur stays in sync with backdrop opacity)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Reorganize folder structure: dissolve Ancillary, nest components

- Dissolve src/essence/Ancillary/ entirely
  - UI components → UserInterface_/components/ (Modal, ConfirmationModal, Help,
    ContextMenu, Compass, MapLogo, CursorInfo, Attributions, Login)
  - Layout chrome → UserInterface_/components/ (Description, Coordinates, Search,
    ScaleBar, ScaleBox)
  - Pure services → essence/services/ (DataShaders, LocalFilterer, QueryURL, Sprites)
  - Stylize.js → design-system/ (theme bridge alongside themeApplier)
  - Delete unused Swap.js

- Nest all components into own folders (ComponentName/ComponentName.ext pattern):
  - UserInterface_/components/: TopBar/, Toolbar/, ToolPanel/, SplitScreens/,
    Splitter/, BottomBar/, BottomElementPositioner/, Layout/, Panels/
  - design-system/components/: Button/, IconButton/, Dropdown/, Toggle/, Modal/,
    Tooltip/

- Update ~70+ import paths across codebase
- Build verified locally

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Modal.set() race condition and themeApplier CSS variable override issue

- Modal.set() onAddCallback: Replace 50ms setTimeout with MutationObserver
  that waits for the modal element to appear in DOM before firing callback.
  Prevents silent jQuery binding failures on slower devices.

- themeApplier: Use Proxy to read computed CSS custom properties (set by
  Stylize.js per-mission overrides) instead of hardcoded theme object values.
  Stylize.js now calls refreshThemeDOM() after setting CSS variables so
  inline styles reflect mission-specific color overrides.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Sync toggleTimeUI DOM state to Zustand store

toggleTimeUI() now calls setTimeUIActive() and setTimeUIExpanded()
so BottomFloatingBar visibility and BottomElementPositioner offsets
reflect actual TimeUI state.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore #toggleTimeUI element in Coordinates markup

The element was accidentally dropped during the folder restructure move.
TimeUI.js and DrawTool.js check $('#toggleTimeUI').hasClass('active')
to gate histogram rendering and time-filter toggle visibility.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix #toggleTimeUI: restore click handler, tippy, active class; remove redundant jQuery positioning

Restored pieces lost during folder restructure:
- Click handler in init() and off handler in remove()
- Tippy tooltip for the time toggle button
- display:none when time is not enabled
- $('#toggleTimeUI').toggleClass('active') so TimeUI.js can check it
- $('#CoordinatesDiv > #toggleTimeUI').remove() on mobile

Removed jQuery CSS positioning from toggleTimeUI() since
BottomElementPositioner now reactively handles all bottom-anchored
element offsets via the Zustand store.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix CurtainTool.destroy() using undefined ReactDOM.unmountComponentAtNode

Use the stored _reactRoot.unmount() instead, matching the React 18
createRoot pattern already used in make().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix tooltips, scale indicator position, modal blur, help close button

Tooltips:
- Reduce Base UI Tooltip delay from 600ms (default) to 200ms
- Restyle tooltip popup to match tippy blue theme (var(--color-c2))
- Add Tooltip wrappers to TopBar panel toggles (Viewer/Map/Globe)
- Wrap Toggle with forwardRef so Tooltip render prop can attach ref
- Remove title attrs that conflicted with custom tooltips

Scale indicator:
- Remove scalefactor-specific positioning from BottomElementPositioner
  (it moves naturally with .leaflet-bottom.leaflet-left container)
- Position scalefactor to the left of compass at same bottom level

Modal blur:
- Call _applyBlur() immediately when marking modal as closing
  so blur clears during fade-out instead of persisting 500ms

Help modal:
- Add close (X) button in title bar matching other modal patterns

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Remove dead CSS: delete tools.css, clean ~600 lines from mmgisUI.css and mmgis.css

- Delete tools.css entirely (both selectors #CurtainToolList and
  .searchToolSelect are unreferenced anywhere in the codebase)
- Remove from mmgisUI.css: .mmgisRadioBar3/4/Vertical (140 lines),
  .mmgispureselect (104 lines), blink/condemned_blink_effect (38 lines),
  .slidecontainer/.slider (41 lines), .ar_slider (91 lines),
  .verticalSlider (91 lines), .mmgisMultirange_elev (19 lines),
  .ui-corner-all/bottom/right/br (9 lines)
- Remove from mmgis.css: #nodeenv, empty #topBar{}, #topBarInfo,
  #topBarHelp, #topBarFullscreen, #toggleUI, #logoGoBack
- Keep #topBarLink (used in BottomBarReact.jsx), #webgl-error-message
  (used by vendored THREE.js)
- All selectors verified with repo-wide grep before removal

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI fixes: tooltips, splitter hover, mobile toolbar, color schemes

- Replace Base UI Tooltip with simple React portal tooltip (200ms delay,
  tippy-matching style) — fixes missing tooltips for toolbar/topbar/bottom buttons
- Add cursor + hover highlight to vertical splitters (was missing because
  module CSS didn't inherit global .splitterV styles)
- Add hover highlight to tool panel drag handle
- Remove mdi-drag-vertical icon from tool panel drag
- Add mobile toolbar horizontal layout via @media query overrides
- Add 4 new color schemes: High Contrast (a11y), Dark Mars, Dark Midnight,
  Light Warm (total: 10 themes)
- Previous fixes also included in working tree:
  - timeUI border moved to toolsWrapper border-bottom (conditional)
  - #toggleTimeUI button removed entirely
  - CoordinatesDiv: vertical centering, unified background, 12px right offset
  - barBottom padding-bottom: 8px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix assignment operator used instead of comparison in TimeControl.fina()

Pre-existing bug: `TimeControl.enabled = true` was assigning instead of
comparing. Changed to `TimeControl.enabled === true`.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restructure Configure page UI tab: add all themes, Custom mode, enableWhenField

- Added all 10 theme presets to dropdown (was missing Dark Mars, Dark Midnight,
  Light Warm, High Contrast)
- Added 'Custom' option: skips preset theme, uses only color picker values
- Moved Theming section directly under Rebranding
- Nested 'Custom Color Options' under Theming with subdescription
- Added enableWhenField support to Maker.js: disables color pickers unless
  theme is set to Custom
- Renamed color options with clearer names and improved descriptions:
  Primary → Surface Color, Secondary → Deep Background Color,
  Tertiary → Text Color, Body → Page Body Color, Highlight → Feature Highlight
- Stylize.js: skip setTheme() when theme is 'Custom'
- Rebuilt configure page

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert tooltips to tippy.js, fix dropdown menus, redesign About modal

1. Tooltip: Replaced custom React portal tooltip with tippy.js wrapper.
   Uses the existing tippy.js dependency and 'blue' theme for consistency.

2. Dropdown: Replaced Base UI Menu with native portal dropdown.
   Base UI's nested Menu.Trigger + BaseButton composition was swallowing
   click events, breaking userAvatar and menuBtn menus. New implementation
   uses simple state + createPortal with proper outside-click dismissal.

3. About modal: Professional redesign with centered MMGIS ASCII art header,
   proper GitHub SVG logo link, clean metadata section, centered link
   buttons, attributions section, and NASA-AMMOS footer.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore .mmgisHelpButton base styles lost during Help.css module migration

The global .mmgisHelpButton styles (yellow color, compact 18x18px sizing,
0.7 opacity) were removed when Help.css was converted to Help.module.css.
Since Help.getComponent() emits raw HTML strings for jQuery-rendered tool
headers, it cannot use CSS Module scoped classes. Restored the base styles
in mmgis.css alongside the related .mmgisToolHelpBtn definition.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix session logout regression, About modal refinements, High Contrast theme, Stylize.js, Default Tool config

- Login: skip session.regenerate() for token-based re-auth (useToken:true)
  so reloading the main page no longer invalidates the configure page session
- About modal: replace ASCII art with mmgis.png logo, rename Attributions to
  Map Layer Attributions, remove footer logo, link NASA-AMMOS to ammos.nasa.gov
- High Contrast theme: change accent from #ffff00 to #ffd700 (gold) for better
  contrast ratios against dark backgrounds
- Stylize.js: color overrides only apply when theme is Custom or unset,
  preventing preset themes from being clobbered by stale config values
- Restore Default Tool config section in tab-ui-config.json (accidentally
  removed during Theming section reorganization)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore defaulttooldropdown case handler in Maker.js

The case was accidentally removed during the Configure page UI tab
restructure (d7f96c50). Without it, the Default Tool dropdown in the
Configure page rendered as nothing despite the config referencing it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* High Contrast tooltip text, panel toggle styling, scale position swap

- High Contrast theme: tooltips now use black text on yellow background
  via --color-c2-text variable (white for all other themes)
- About modal links use var(--color-f) for consistent theme text color
- Panel toggle buttons: 11px uppercase with 600 weight for better
  visibility
- Mapping scale button moved to bottom-right of compass (was top-left)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Reposition Viewer and Globe panel buttons to top-right

- Viewer: dropdown selector at top-right edge, OSD buttons stacked
  vertically below it; settings panel opens to the left
- Globe: home, exaggerate, observe, walk, link controls moved from
  TopLeft to TopRight corner via addControl 4th arg
- Style consistency: OSD buttons and LithoSphere controls now match
  Leaflet zoom controls (var(--color-a) bg, var(--color-f) text,
  var(--color-mmgis) hover, 30px size, 3px border-radius)
- Viewer settings sliders use var(--color-a3) instead of hardcoded
  #444444
- Az/el indicator stays at bottom center (exception per design)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Consistent modal theming + session security fix

Modal theming:
- All modals now share consistent styling: backdrop-filter blur, semi-transparent
  background via --color-a-rgb, 10px border-radius, header divider line, box-shadow
- Updated: loginModal, Help, ConfirmationModal, Settings, Hotkeys, About modals
- Tool panel backgrounds changed from opaque var(--color-k) to transparent so the
  ToolPanel's existing backdrop-filter effect shows through
- Legend tool header updated to match consistent 44px height with divider
- applyTheme.js now auto-derives --color-a-rgb from theme's --color-a hex value
- Modal service wrapper gets backdrop-filter: blur(12px)

Session security (Devin Review fix):
- Token re-auth now calls req.session.regenerate() with data preservation to
  prevent session fixation while maintaining multi-tab compatibility
- Token is rotated via crypto.randomBytes on every re-auth (was being reused)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI fixes: viewer settings, button sizing, menu contrast, coords, login header

1. Viewer OSD settings moved to top of button stack, panel opens downward
2. Overlay buttons consistent 30x30px (OSD line-height fix, home button)
3. Menu/icon contrast improved: Dropdown items and IconButtons use --color-a5
   (was --color-a3) with --color-f on hover for better dark theme legibility
4. CoordinatesDiv fixed to 30px height, pickLngLat button centered
5. Login modal now has a header bar with 'Log In' title and close X button;
   title toggles to 'Sign Up' when switching modes

Also reverts session regeneration for token re-auth (Devin Review feedback):
token-based re-auth now refreshes session data in-place without regeneration
or token rotation, preserving multi-tab compatibility.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI polish: nav popover, sep tools, compass, zoom controls, status indicator, toast

1. Description nav popover: added z-index:9000 so menu appears above map panels
2. Separated tools: default color changed from accent to --color-f; fixed CSS
   selector from .toolButtonSep to .toolSep to match actual class names
3. Compass + mapping scale shifted left by 30px for better positioning
4. Map zoom/home controls: use --color-f instead of accent --color-c to reduce
   visual prominence; hover still highlights with accent color
5. Status indicators (reload/ws disconnect/layer update) moved from Leaflet
   control to TopBar with soft pulsing fade animation and tooltip on hover
6. WebSocket retry toast: rounded corners, glass background with backdrop-filter,
   border-left accent for failure state instead of solid red background

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix 14 tool UI issues: headers, backgrounds, layout, functional bugs

Header alignment:
- LayersTool: align-items center on #filterLayers, gap between right icons
- InfoTool: align-items center on #infoToolHeader, 44px height
- ViewshedTool: align-items center, restructured header with left/right divs
- IsochroneTool: restructured flat header into nested mmgisToolHeader pattern
- ShadeTool: align-items center on #vstHeader children

Icon spacing:
- LayersTool: increased right margin to 28px + gap 2px
- ViewshedTool: #vstNew padding-right 30px (clear of close button)
- IsochroneTool: #iscNew padding-right 30px

Missing components:
- SitesTool: added Help import + help icon via mmgisToolHeader pattern
- AnimationTool: added full mmgisToolHeader with title and help icon

Background fixes:
- InfoTool: changed toolsContainer background from transparent to var(--color-a)
- DrawTool: changed toolsContainer background from transparent to var(--color-a)

Layout fixes:
- DrawTool: #drawToolContents top 81px, height calc(100%-81px), #drawToolNav margin-right 0
- MeasureTool: removed padding-left:0 override from mmgisToolHeader child selector

Functional fixes:
- InfoTool: updated jQuery selectors from #InfoTool to #toolButtonInfo (React toolbar IDs changed)
- CurtainTool: deferred OpenSeadragon init with requestAnimationFrame (React 18 async render)
- CurtainTool: curtainToolBar justify-content flex-end (icons at bottom)

Security:
- TopBar StatusIndicator: escape HTML in layer names to prevent XSS via addLayerQueue

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix mapToolBar pointer events, login padding, default tool, About modal order

1. mapToolBar: set pointer-events:none on both #mapToolBar and direct children
   so clicks pass through to the map; leaf elements still get auto via
   .childpointerevents rule
2. #loginModalBody: padding changed to 40px 0px 0px
3. Default tool: deferred click to requestAnimationFrame so React toolbar
   has rendered before getElementById runs
4. About modal: moved mainInfoModalCustom to right below mainInfoModalHero

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix tool headers to 40px, ViewshedTool subheader, AnimationTool header, InfoTool close btn, statusIndicator position

1. All tool panel headers: changed from 44px to exactly 40px height
   - Global .mmgisToolHeader and .mmgisToolTitle in mmgis.css
   - InfoTool.css, ViewshedTool.css, IsochroneTool.css, ShadeTool.css

2. LayersTool #filterLayers: height 40px, .right > div height unset,
   .right margin-right 30px, .right > div margin 0px 3px

3. ViewshedTool: restructured header — title+help in mmgisToolHeader row,
   vstToggleAll (left) and vstNew (right) on a new #vstSubHeader row below

4. AnimationTool: removed old #animationToolHeader CSS (padding 15px 20px,
   white background, 18px font), now uses standard mmgisToolHeader class.
   Fixed color from var(--color-a) (background) to var(--color-f) (text)

5. InfoTool close X: re-inject close button after use() rebuilds content
   via TC_.injectCloseButton() (toolsContainer.empty() was removing it)

6. StatusIndicator: moved to left of topBarTitle in JSX render order.
   Added align-items:center to #topBarMain for vertical alignment.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Remove title attr from StatusIndicator (conflicts with tippy tooltip)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TimeUI dropdown z-index above tool panel, reposition toasts to top-center

1. TimeUI dropdowns: added z-index 10000 to all dropy content ul elements
   so they render above the vertical tool panel (z-index 1400). Also set
   timeUIDock to position:relative with z-index 10000 and overflow:visible.

2. Toast notifications: repositioned #toast-container from bottom-right to
   top-center just below the topbar (top: 44px, centered with transform).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix StatusIndicator spacing, CurtainTool close btn, header title font consistency

1. StatusIndicator: use display:none/flex instead of opacity:0/1 so it
   takes no space when there's no active status indicator.

2. CurtainTool: added close X button at top of curtainToolBar (matching
   MeasureTool pattern) with flex spacer pushing other buttons to bottom.

3. Header title font consistency: InfoTool, ShadeTool, CurtainTool titles
   now match mmgisToolTitle standard (font-weight:600, padding-left:10px,
   height:40px for CurtainTool which was 34px).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Legend empty state, scalefactor position, sep-tool-header unbold

1. Legend: show 'No active layers with legends' when no legend items
   are present. Also fixed container height calc(100% - 40px).

2. Mapping Scale (.leaflet-control-scalefactor): shifted 10px right
   (left 26→36px) and 1px down (bottom 30→29px).

3. sep-tool-header span: font-weight changed from 600 to 400.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Guard Legend empty state message to only show when panel is active

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TimeUI dropdown covered by toolPanel: remove splitscreens stacking context

#splitscreens had z-index:1 which created a stacking context, confining
its children (including bottomFloatingBar at z-index:1500) to that context.
Since ToolPanel (z-index:1400) was a sibling outside splitscreens, it
painted above all splitscreens children regardless of their internal
z-index values.

Fix: change #splitscreens z-index from 1 to auto so it no longer creates
a stacking context. Now bottomFloatingBar (1500) participates in the
same stacking context as ToolPanel (1400), and 1500 > 1400 means the
TimeUI dropdown correctly paints above the tool panel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Migrate ~69 CursorInfo toast-like calls to proper Toast component

- Replace CursorInfo.update() toast-like calls with Toast.info/success/warning/error
- Preserve message colors: blue→info, green→success, yellow→warning, red→error
- Keep 12 legitimate cursor-following CursorInfo calls unchanged
- Files migrated: DrawTool.js, DrawTool_Files.js, DrawTool_FileModal.js,
  DrawTool_Templater.js, DrawTool_SetOperations.js, DrawTool_Drawing.js,
  DrawTool_Editing.js, DrawTool_Shapes.js, LayersTool.js, ShadeTool.js,
  chemistrychart.js
- Fix Devin Review: Change misleading 'Bad token' to 'Login failed' in users.js
- Normalize line endings (CRLF→LF) in affected files

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Toast.js missing from git, CoordinatesDiv z-index, Legend duplicate ID, topBar padding

- Add Toast.js to version control (was untracked, causing webpack module error)
- Bump CoordinatesDiv z-index from 20 to 1001 (was hidden behind splitscreens
  children after z-index:auto change)
- Fix Legend duplicate ID: separated tool icon was #LegendTool, same as content
  container div, causing empty message to appear in button instead of panel
- Add hasStatus class to #topBarMain when statusIndicator is active, setting
  #topBarTitleName padding-left to 0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Legend empty message: scope selector to content container via targetId

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Move Toast.js to design-system, fix --color-a3 text contrast, update AGENTS.md

- Move Toast.js from UserInterface_/components/Toast/ to design-system/components/Toast/
  (generic component belongs in design-system, not MMGIS-specific UserInterface_)
- Update all 11 Toast import paths to new location
- Bump --color-a3 in 5 dark themes to pass WCAG AA 4.5:1 contrast for text:
  Dark Default #747c81→#81888d, Dark Blue #64748b→#738399,
  Dark Warm #8b7a5e→#908064, Dark Mars #8a6a60→#98796f,
  Dark Midnight #606088→#7a7a9e
- Update AGENTS.md: document design-system/ vs UserInterface_/ distinction
  in project structure and Key Directories

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix topBarTitleName padding override: increase specificity and add !important

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix IdentifierTool deactivation: update icon ID reference in separateFromMMWebGIS

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Hide toolPanelDrag when no tool is open

- Add setToolPanelDragVisible(false) to closeActiveTool() (was only in makeTool toggle-off path)
- Also guard drag handle display on isOpen (toolPanelWidth > 0) as safety net

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add hover effect to MMGIS logo (opacity + brightness transition)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix MMGIS logo hover: keep full opacity, use subtle background highlight instead

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Selective tile fade: fade on pan/zoom, instant on refresh/reload

- Remove blanket _fadeAnimated toggle from toggleTimeUI (was killing fade
  for all tiles while TimeUI was open)
- Monkey-patch GridLayer.redraw, TileLayer.setUrl, and GridLayer._tileReady
  to suppress fade via a transient _suppressTileFade map flag
- Set _suppressTileFade in reloadTimeLayers for time-driven reloads
- Flag auto-clears after 300ms so pan/zoom tiles still get the nice fade
- Install pbf dependency (required by CesiumMVTLayer from #942 merge)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Per-layer fade control: time-enabled + shade/viewshed layers never fade

- Replace transient map-level _suppressTileFade with per-layer _noFade flag
- Patch GridLayer._tileReady to check _noFade on the layer instance
- Set _noFade on time-enabled tile layers and data/GL layers at creation
- Set _noFade on Shade and Viewshed tool GL layers
- Non-time-enabled base imagery tiles still fade normally on pan/zoom

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile UI improvements — move hamburger to right menu, show panel toggles, isMobile-driven toolbar layout, desktop-matching scalebar/compass

- Remove left hamburger menu (#topBarMenu), move BottomBar items into
  top-right kebab dropdown menu for both mobile and desktop
- Show panel toggles (Viewer/Map/Globe) and account/login UI in mobile
  topbar's #topBarRight
- Move toolbar horizontal layout CSS from @media breakpoints to
  UserInterfaceMobile_.css (loaded only when isMobile flag is true)
- Remove #mapTopBar @media rule from mmgis.css, add to mobile CSS
- Remove mobile-only simplified scalebar rendering; use full desktop
  scalebar with both large and small axes on all viewports
- Remove display:none on .leaflet-control-scalefactor in mobile CSS
- Remove #loginDiv display:none from mobile CSS (React overlay handles it)
- Simplify BottomBarReact container styles (no more absolute positioning)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260430 [version bump]

* fix: mobile toolbar 40px height, bottomFloatingBar flush, timeUI toggle, scalebar position, hide hotkeys

- Toolbar height 40px, toolButton width 40px, no border-bottom
- toolcontroller_incdiv: no padding-bottom, overflow-y hidden
- bottomFloatingBar: no border-radius, left/right/bottom = 0
- Add MobileTimeUIToggle button on far right of toolbar
- Hide Keyboard Shortcuts from kebab menu on mobile
- Fix scalebar positioning (remove top:48px override in UserInterfaceBridge)
- Set mobileTopSize/topSize to 40 (splitscreens top = 40px, not 50px)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile topBar padding-left 34px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: update MobileCoordButton topBar paddingLeft from 80px to 34px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: MobileTimeUIToggle — inline toggle logic, float right, hide from settings on mobile

- Replace broken Coordinates.toggleTimeUI() call with direct jQuery/store toggle
- Float time button right in toolbar
- Hide Time UI toggle from settings modal on mobile (toolbar has it)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: push scalebar/compass/scale up 40px on mobile, keep #timeUI in DOM

- BottomElementPositioner: position mapToolBar, leaflet-bottom-left/right
  40px above bottom on mobile (above toolbar)
- Stop removing #timeUI from DOM on mobile so MobileTimeUIToggle works

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — only show endtime, always expanded

- Hide #mmgisTimeUIStartWrapper and StartWrapperFake on mobile via CSS
- Force expanded state (addClass expanded + show) when toggling TimeUI on
- CSS ensures #timeUI.active always shows expanded content on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI opens in tool panel with header, end time, expanded rows

- MobileTimeUIToggle now opens/closes the tool panel via ToolController_
- Closes any active tool before showing TimeUI
- Forces expanded state when opening
- CSS hides start time inputs, positions expanded content properly
- Overrides absolute positioning of expanded content for tool panel flow

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: rewrite separated tools system from jQuery to React components

- Add separatedToolsList/activeSeparatedTools state to Zustand uiStore
- Rewrite SeparatedTools.jsx with glassmorphism panels, CSS Module styling
- Replace SepToolsContainer (setInterval hack) with SepToolButton/SepToolsSection
- Remove ~170 lines of jQuery DOM construction from ToolController_.js
- Fix hardcoded rgba(26,26,27,0.88) to theme-aware var(--color-a-rgb)
- Remove separated tool entries from themeApplier.js
- Remove separated tool overrides from FloatingElements.css
- Move Legend CSS overrides from Toolbar.module.css to SeparatedTools.module.css
- Remove jQuery active-state manipulation from IdentifierTool.js
- Add store sync in Map_.js displayOnStart logic
- Preserve all DOM IDs for backward compatibility (mmgisAPI, tool make())

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260501 [version bump]

* fix: TimeUI mobile checks — use Zustand store instead of L_.UserInterface_

L_.UserInterface_ is null when TimeUI.init() runs (TimeControl.init is called
before L_.link sets UserInterface_). All 16 isMobile checks now read from
useUIStore.getState().isMobile which is set at startup.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: move displayOnStart logic from Map_.js to ToolController_.finalizeTools()

- Map_ no longer references specific tools (LegendTool)
- displayOnStart is now handled generically for all separated tools
- Added DOM element polling (tryMake) to handle React render timing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* revert: remove all TimeUI-related mobile changes

Reverts TimeUI.js and BottomBar.js to development base.
Restores #timeUI DOM removal in UserInterfaceBridge.fina().
Removes MobileTimeUIToggle component from Toolbar.jsx.
Removes TimeUI mobile CSS overrides from UserInterfaceMobile_.css.
Non-TimeUI refinements (toolbar height, scalebar positioning, etc.) preserved.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* simplify: remove DOM polling, use simple setTimeout(0) for auto-open

LegendTool handles its own content lifecycle via subscribeOnLayerToggle.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: mobile TimeUI — fix isMobile detection, staging container, toolbar toggle

- TimeUI.js: import useUIStore and replace all 16 L_.UserInterface_?.isMobile
  checks with useUIStore.getState().isMobile (L_.UserInterface_ is null when
  TimeUI.init() runs, so mobile conditionals were dead code)
- TimeUI.js: stage mobile #timeUI in hidden #timeUIMobileStaging instead of
  placing directly in #tools (which gets cleared by other tools)
- UserInterfaceBridge.js: stop removing #timeUI from DOM on mobile
- Toolbar.jsx: add MobileTimeUIToggle that moves #timeUI between staging and
  #tools, opens/closes tool panel via ToolController_
- BottomBar.js: hide TimeUI toggle from settings modal on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: rescue #timeUI back to staging when another tool opens

Subscribe to activeToolName changes — when a tool becomes active while
TimeUI is showing, move #timeUI back to #timeUIMobileStaging before
the new tool's make() clears #tools.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove separatedTool/justification config toggles, fix review issues

- Remove separatedTool checkbox and justification dropdown from Legend
  and Identifier config.json (these are always separated, not configurable)
- Remove justification property/code from LegendTool.js, IdentifierTool.js
- Simplify Globe_.js separated tool count (no justification filter)
- Remove justification from Reference-Mission config blueprint
- Update LegendTool help docs and Legend.md documentation
- Add --color-a-rgb fallback (29,31,32) in SeparatedTools.module.css
- Add display:none !important to .panelIdentifier to prevent 12px gap
- Update e2e test comment

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: circular import in TimeUI.js, toolbar/bottomFloatingBar position sync

- TimeUI.js: replace top-level useUIStore import with lazy _getUIStore()
  accessor to avoid 'Cannot access useUIStore before initialization'
  circular import error at _remakeTimeSlider
- SplitScreens.jsx: skip #timeUI reparenting observer on mobile (mobile
  uses MobileTimeUIToggle to manage #timeUI placement in #tools)
- BottomElementPositioner.jsx: unify mobile transition to 0.3s (matches
  toolsWrapper and toolbar), guard pxIsTools against undefined
- Toolbar.jsx: align toolbar transition to 0.3s ease-out

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* LegendTool fix empty message

* chore: remove separated tools offset logic from Globe_.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: skip _makeHistogram on mobile (no timeline slider, timestamps unset)

_makeHistogram renders inside the timeline slider which doesn't exist
on mobile. Without it, _timelineStartTimestamp is NaN, causing
'Invalid time value' RangeError at toISOString().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — populate expanded rows, fix Invalid date, fix panel height

- TimeUI.js attachEvents: use _initialStart/_initialEnd on mobile (same
  as desktop) instead of L_.TimeControl_ which isn't set yet at init time.
  Fixes 'Invalid date' in start/end time inputs.
- TimeUI.js fina: set expanded=true on mobile and call _populateExpandedRows()
  so year/month/day/hour rows actually render. Removed position:absolute and
  pointer-events:none overrides.
- Toolbar.jsx: set tool panel height to 217px (TimeUI.height) instead of
  45% viewport — matches actual TimeUI content height.
- UserInterfaceMobile_.css: expanded content flows naturally (position:relative),
  hide start time inputs, allow overflow scroll, flex-wrap topbar.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI justify-content center, restore toolbar border-bottom

- Add justify-content: center to #mmgisTimeUIMain on mobile
- Remove border-bottom: none override so toolbar keeps its default border

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI overflow hidden, scalebar/compass fixed at 40px offset

- #timeUI overflow-y: hidden (was auto, causing 2px scroll)
- Scalebar/compass/map controls stay at fixed 40px offset (above toolbar)
  regardless of tool panel state — no longer shift up by pxIsTools

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Implement multi-tier knowledge architecture

- Restructure AGENTS.md from 745 lines to 106 lines (Tier 1: essential context)
- Create knowledge/ directory with 30+ wiki-style documentation files (Tier 2: deep knowledge)
- Create knowledge/reference/ with 8 detailed reference files (Tier 3: lookup material)
- Move AI-GETTING-STARTED.md and AI-DEVELOPMENT.md to knowledge/
- Update all file references in .specify/templates and blueprints
- Create knowledge/README.md as the full knowledge base index
- Create knowledge/reference/README.md as reference material index

Three-tier knowledge discovery system:
  Tier 1: AGENTS.md (~106 lines) - scannable in <2 minutes
  Tier 2: knowledge/*.md - deep knowledge on architecture, tools, APIs, DB, infra
  Tier 3: knowledge/reference/*.md - coding conventions, API reference, troubleshooting

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: mobile toolbar active button style matches desktop, fix icon alignment

- All mobile toolbar buttons (ToolButton, MobileCoordButton, MobileTimeUIToggle)
  now use display:flex with align-items/justify-content center for proper
  vertical icon centering
- MobileCoordButton: changed 'active' class to 'toolButtonActive' to match
  the global CSS active style (color-mmgis + color-i background)
- Removed inline color overrides so CSS .toolButtonActive takes effect

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add Devin knowledge notes from past MMGIS sessions

Include curated lessons learned from past Devin sessions:
- CI/CD: ignore build-arm64/amd64 failures, focus on required checks
- Child sessions: no separate PRs when consolidating
- ENV triple-update rule (.env, sample.env, ENVs.md)
- Error handling: use logger with infrastructure_error for fatal startup errors
- Path traversal security: stay within /Missions, handle subpath serving
- Database initialization architecture and migration patterns
- API authentication behavior across AUTH modes
- Auto-generated MMGIS concept index

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar active button style, icon alignment, tool deactivation

- Active toolbar buttons get desktop-matching margin (1px 0) and
  border-radius (8px) via .toolButton.toolButtonActive CSS rule
- Removed line-height: 40px from .toolButton (flex centering handles
  vertical alignment, line-height was pushing icons up)
- MobileCoordButton now watches activeToolName store and deactivates
  when another tool opens (fixes coords staying active)
- MobileTimeUIToggle sets activeToolName='MobileTimeUI' when opening
  so coords/other buttons can detect it and deactivate
- MobileTimeUIToggle clears activeToolName when closing
- Both custom buttons skip self-deactivation via name check

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix cross-references: convert backtick refs to markdown links, add Devin knowledge notes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon height 40px, button margins for active padding

- #toolbar .toolButton i: height 40px fixes icon vertical alignment
- #toolbar .toolButton: margin 0 2px gives spacing between buttons
- #toolbar .toolButton.toolButtonActive: margin 1px 2px so active
  background has visual padding around the icon

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename knowledge/ to .knowledge/ for consistency with .specify/ convention

Dot-prefix signals agent infrastructure (not source code), consistent with
.specify/, .github/, .vscode/ conventions. All cross-references updated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon line-height 40px, active button padding via height

- Coord and TimeUI button <i> icons get line-height: 40px
- Active buttons: height 34px (vs 40px toolbar) creates visual padding
  around the active background, centered by flex align-items
- Buttons get margin: 0 1px for horizontal spacing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix broken cross-reference: 06.2 -> 06.1-configure-rest-api.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: close active tool + cancel deferred cleanup in MobileCoordButton/TimeUI

- MobileCoordButton: call closeActiveTool() before opening, destroy
  _pendingCloseTool if set, increment _closeSeq to cancel deferred
  tools.innerHTML clear
- MobileTimeUIToggle: same _pendingCloseTool + _closeSeq fix after
  closeActiveTool() to prevent 420ms deferred cleanup from wiping
  #timeUI after it's placed in #tools
- Removed redundant closeActiveTool() from MobileCoordButton close path
  (was being called after destroy, not needed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: active mobile toolbar buttons 34x34px (square)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Drastically compress .knowledge/ — keep only unique agent content

Remove 33 wiki files that duplicate docs/pages/ content.
Remove 9 reference/ files derivable from source code.
Keep only 5 files (down from 46):
  - AI-GETTING-STARTED.md (agent setup walkthrough)
  - AI-DEVELOPMENT.md (spec-kit workflow)
  - conventions-and-gotchas.md (naming, code style, common issues)
  - 12-devin-knowledge-notes.md (CI, auth, DB init, security gotchas)
  - README.md (index pointing to docs/pages/ for everything else)

Principle: don't duplicate docs/ — only keep what's uniquely agent-optimized.
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename to knowledge-notes.md, remove Devin branding and fork-specific CI section

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: hide mmgis-map-logo on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore Database Safety Rules for AI Agents section in AGENTS.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: shift compass and map scale 6px to the right (both mobile and desktop)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add back Important Instructions, code pattern templates, and detailed project structure

- Important Instructions in AGENTS.md: MCP tools, hot-reload, Reference Mission
- .knowledge/code-patterns.md: full directory tree with key directory annotations,
  plus copy-paste templates for Express routes, Sequelize models, Tool plugins,
  and WebSocket handlers

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Update project structure trees to reflect current filesystem

Add missing directories: tests/, .knowledge/, .specify/, .github/, views/,
private/, spice/, build/, examples/, scripts/middleware.js.
Both abbreviated (AGENTS.md) and detailed (.knowledge/code-patterns.md) trees
now match the actual repo layout.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.30-20260501 [version bump]

* Add Layers_.js to project structure (key singleton L_)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix project structure: correct API layout, frontend modules, code templates

API/Backend/ uses feature-domain modules (Draw/, Users/, Config/, etc.)
with setup.js + routes/ + models/ per feature — not APIs/ or Databases/.
Frontend essence/ has Components/, Helpers/, LandingPage/, mmgisAPI/,
services/ — not Ancillary/. Basics/ includes all singletons (Globe_,
Formulae_, ToolController_, Viewer_, ComponentController_, Test_).
Code templates updated to match actual patterns (setup.js, module.exports).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove test infrastructure (Test_ module, testModules, DrawTool.test)

- Delete src/essence/Basics/Test_/ directory
- Delete src/essence/Tools/Draw/DrawTool.test.js
- Remove Test_ import and Shift+T keydown handler from essence.js
- Remove tests key from Draw tool config.json
- Remove testModules generation logic from API/updateTools.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.31-20260501 [version bump]

* style: move Cesium link button to top-right and match Leaflet zoom button styling

- Change control container from top-left to top-right positioning
- Update button size from 26px to 30px to match Leaflet zoom controls
- Use CSS variables (--color-a, --color-f, --color-mmgis) instead of hardcoded colors
- Add border-radius and box-shadow matching Leaflet control appearance
- Update hover/inactive states to use themed colors

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: anchor map logo to viewport instead of Leaflet map panel

- Change MapLogo parent from .leaflet-bottom.leaflet-right to #main-container
- Switch CSS position from absolute to fixed for viewport anchoring
- Add explicit bottom-offset positioning in BottomElementPositioner (desktop)
- Add explicit bottom-offset positioning in BottomElementPositioner (mobile)
- Logo stays at viewport right edge regardless of open side panels
- Retains smooth bottom offset transitions when bottom bar appears

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: remove references to deleted test infrastructure (Test_, DrawTool.test)

- Remove Test_/ from project structure in .knowledge/code-patterns.md
- Remove DrawTool.test.js references from specs/006 spec, plan, and tasks
- Remove Draw Tool Testing section from tasks.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.32-20260501 [version bump]

* fix: append logo to document.body to avoid filter containing block

#main-container has a CSS filter property which creates a new containing
block per the CSS spec, causing position:fixed to behave like absolute.
Appending to document.body ensures true viewport-fixed positioning.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: prevent mobile topBarTitleName text wrapping by replacing max-width with white-space: nowrap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.33-20260501 [version bump]

* chore: bump version to 5.0.0 and update changelog

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(ui): move Screenshot/Fullscreen to BottomBar, About to TopBar kebab

TopBar kebab menu now contains only Keyboard Shortcuts, Settings, and About
(About now shows on both desktop and mobile).

BottomBarReact now renders Screenshot, Fullscreen, and Copy Link buttons
(top to bottom) following the same IconButton + Tooltip pattern. The
About button has been removed from BottomBarReact.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(mobile): enforce exclusive panel toggling on mobile in TopBar

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* style: reposition LithoSphere globe controls to match Leaflet/Cesium theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(topbar): hide Viewer/Globe toggles based on configured panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): reorder buttons (Copy Link, Screenshot, Fullscreen) and unify size

Reorder the BottomBarReact buttons top-to-bottom to: Copy Link, Screenshot,
Fullscreen.

Move the 24x24 button sizing from the #topBarLink id selector in mmgis.css
into the .barButton class in BottomBarReact.module.css so all three buttons
share the same compact size as the original Copy Link button. Drop the now
redundant #topBarLink rule.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): increase padding-bottom to 12px and button margin to 3px 0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: rearrange globe controls — compass top-right circular, nav row, vertical column, panels open left

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* chore: bump version to 5.0.2-20260505 [version bump]

* style: anchor observe settings panel right:34px and float nav hover panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(theming): add 5 new themes, --color-shadow variable, and configure ThemePreview

- Add Dark Terra, Dark Nebula, Dark Lunar, Dark Supernova, Light Botanical themes
- Add --color-shadow CSS variable to every theme + :root fallback
- Replace hardcoded rgba shadow colors with var(--color-shadow) in TopBar,
  Toolbar, SeparatedTools, ToolPanel, FloatingElements, Dropdown, Modal,
  and SplitScreens
- Add Custom shadowcolor color picker in tab-ui-config and apply it via Stylize
- Add ThemePreview component (configure/src) wired through Maker.js as
  a new 'themepreview' row type so the configure UI shows a live mini
  mockup of the selected theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* fix(configure/ThemePreview): tighten top spacing and live-preview Custom theme

- Pull the preview up by 12px so the gap below the theme dropdown is tighter.
- Read the Custom color pickers (look.primarycolor / secondarycolor /
  tertiarycolor / accentcolor / shadowcolor / topbarcolor / toolbarcolor /
  mapcolor) from the configuration and overlay them on Dark Default so
  the preview reflects Custom theme edits live, matching Stylize.js's
  runtime behavior.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260505 [version bump]

* feat(themes): add Dark Heliosphere, Dark Monokai, and Light Solarized

- Dark Heliosphere: deep night purple surface with corona-orange accent.
- Dark Monokai: warm graphite surface with lime accent (Monokai-inspired).
- Light Solarized: classic solarized base3/base02 with blue accent.

Mirror added to configure/src/themes/themes.js for the ThemePreview, and
the three names appended to the Color Theme dropdown options.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(coordinates): respect time.initiallyOpen when live deep-link is set

* chore: bump version to 5.0.3-20260505 [version bump]

* refactor(theming): remove Custom theme + per-field color overrides

- Drop the 'Custom' option from the Color Theme dropdown.
- Remove all Custom Color Options (look.primarycolor, .secondarycolor,
  .tertiarycolor, .accentcolor, .bodycolor, .topbarcolor, .toolbarcolor,
  .mapcolor, .hightlightcolor, .shadowcolor) from tab-ui-config.json.
- Strip the matching DOM/CSS-variable override block from Stylize.js;
  Stylize now just applies the selected preset theme (and the page logo).
- Drop the empty bodycolor/topbarcolor/toolbarcolor/mapcolor/shadowcolor
  defaults from API/templates/config_template.js.
- Simplify ThemePreview to render the selected preset directly — no
  Custom branch, no overlay logic.

Preset themes cover all the looks we want and keep the configure surface
much smaller.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(time-ui): round corners on TimeUI shell, action wrappers, mode dropdown

- #timeUI: 10px border-radius on the outer time control bar.
- #mmgisTimeUIActionsLeft / #mmgisTimeUIActionsRight: 10px border-radius
  so the action clusters sit as rounded chips.
- #mmgisTimeUIActionsRight > div (excluding #mmgisTimeUIPresent): 10px
  border-radius on each action button so they match the wrapper.
- #mmgisTimeUIModeDropdown: 40px height + 10px border-radius to align
  with the rest of the bar; clear the dropy default border-color so the
  rounded edge isn't outlined.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260505 [version bump]

* feat(configure): mark light themes as (experimental) in dropdown label

Light themes still have outstanding contrast issues, so flag them in the
Color Theme dropdown without changing the saved value.

- Maker dropdown now accepts options as either a plain string (current
  behavior) or { value, label } so the rendered label can differ from
  the persisted value.
- tab-ui-config switches the six light themes to { value, label } form
  with '(experimental)' appended to the label only. Existing mission
  configs that already saved 'Light Default' etc. continue to match.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix timeUI border radius

* fix(mobile): rescue #timeUI before tool make() destroys it

Clicking Layers -> Time -> Layers -> Time on mobile caused the bottom
panel to render LayersTool content with TimeUI height. The #timeUI DOM
element was destroyed when LayersTool.make() called $('#tools').empty(),
before the async React useEffect in MobileTimeUIToggle could rescue it
to its staging container.

- ToolController_.makeTool: synchronously move #timeUI from #tools back
  to #timeUIMobileStaging (and reset TimeUI store flags) on mobile,
  before invoking the new tool's make().
- MobileTimeUIToggle.handleClick: defensive fallback that re-initializes
  TimeUI if #timeUI no longer exists when the toggle is activated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): move re-initialized #timeUI from staging into #tools

TimeUI.init() on mobile appends the new #timeUI to the hidden
#timeUIMobileStaging container, so the fallback branch must also move
it into #tools — otherwise the user sees an empty tool panel after
the destroyed-element recovery path.

Caught by Devin Review.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): preserve #timeUI when Coordinates tool empties #tools

On mobile, opening or closing the Coordinates tool runs
$('#tools').empty() inside interfaceWithMMWebGIS / separateFromMMWebGIS.
After the previous PR commits, clicking Coordinates -> Time still left
the bottom panel empty because:

- Coordinates.make() empties #tools while #timeUI is in staging (fine
  on its own), but the Coordinates teardown that fires after the user
  switches to the Time toggle (via MobileCoordButton's useEffect on
  activeToolName change) calls Coordinates.destroy() ->
  separateFromMMWebGIS(), which empties #tools wholesale and destroys
  the freshly-placed #timeUI.

Add a rescueMobileTimeUI() helper that moves #timeUI from #tools back
to #timeUIMobileStaging before each tools.empty() call in Coordinates,
mirroring the rescue already done in ToolController_.makeTool().

Coordinates -> Time now correctly shows the TimeUI.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): harden TimeUI fallback recovery (call fina(), de-dupe popovers)

Devin Review correctly flagged that the safety-net path in
MobileTimeUIToggle.handleClick was producing a partially-broken TimeUI
when it fired:

- TimeUI.init() unconditionally appends a new #timeUIPlayPopover_global
  to <body>, so a second init() left two elements with the same id.
- TimeUI.init() alone does not wire up date pickers or per-button click
  handlers — that's TimeUI.fina()'s job. Without fina(), the recovered
  TimeUI rendered visually but Play / Previous / Next / Fit / Follow /
  Present / Expand were all dead.

Before re-initializing, remove the stale #timeUIPlayPopover_global and
#timeUIQuickSelectPopover_global divs to avoid duplicate ids. After the
new #timeUI is moved into #tools, call TimeUI.fina() to populate the
date pickers, attach the button click handlers, build the histogram,
and populate the expanded mobile rows.

Some delegated body/document handlers in attachEvents() will still be
duplicated on this path; that is acceptable for a degraded recovery
that should never run in practice now that the primary rescues in
ToolController_.makeTool() and Coordinates.js cover all known paths.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260505 [version bump]

* fix(mobile): Coordinates teardown only removes its own DOM

The previous Coordinates fix was racing with itself: after the Time
toggle synchronously moved #timeUI into #tools, MobileCoordButton's
useEffect (triggered by the activeToolName change) ran on the next
React tick and called L_.Coordinates.destroy(). That called
separateFromMMWebGIS(), whose rescue moved #timeUI right back into the
hidden staging div before tools.empty() — so the bottom panel ended up
empty even though the time toggle was 'active'.

Make separateFromMMWebGIS selective: only remove the
Coordinates-specific DOM (#coordUIHeader and #CoordinatesDiv) instead
of wiping all of #tools. Any other content already in #tools (e.g.
#timeUI placed there by the Time toggle) is left alone.

interfaceWithMMWebGIS still keeps the rescue + tools.empty() pattern
on the open path so Coordinates always starts from a clean panel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Bump DrawTool Temporal Drawings upward

* chore: bump version to 5.0.6-20260505 [version bump]

* chore: reset version to 5.0.0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test(e2e): fix 9 pre-existing failures (test-only changes)

- mmgis-api.spec.js: add form-fill login under AUTH=local; serialize
  describe to avoid concurrent-login race in the session store
- coordinates.spec.js: TimeUI toggle was moved from the coordinates bar
  to the Settings modal; navigate via topbar kebab menu and assert the
  checkbox is rendered
- widgets.spec.js: target .leaflet-control-zoom-in/-out specifically;
  the bare .leaflet-control-zoom class is also used by the home/reset
  control, so the original assertion was always false
- sites.spec.js: scope panel selector to #toolPanel; both the toolbar
  icon and the panel container share id="SitesTool"

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* Revert "chore: bump version to 5.0.1-20260505 [version bump]"

This reverts commit 4880204c1163be5d1d7fa96d14a0ed018c6f586c.

* fix: prevent filter operator dropdown clipping in Layers panel

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260507 [version bump]

* revert: keep dropy openUp:true for operator dropdowns

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit d67c369ed437e47d658ae051348d377978dc48ed.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 29565ed829a55e9c241a789c9a3901d11cb5ca67.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 50e357604ebe9378564619b34c508b63cfb62c1d.

* chore: bump version to 5.0.1-20260507 [version bump]

* chore: bump version to 5.0.2-20260511 [version bump]

* fix: render Globe panel immediately on first open without window resize

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260511 [version bump]

* feat: add theme borders to panels and gradient backgrounds to splitters

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: bump split shadow gradient opacity to 0.4

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal 3-col grid + smaller leaflet zoom button gap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: prevent hotkey label/value wrapping (ellipsis instead)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal single column, no wrap, no truncation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: hotkeys modal dividers, invert title/subtitle colors, rename title, margin above subtitles

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: move splitter gradient to themed CSS class, restore hover feedback

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* style: hotkeys section titles use --color-h (matches rest of app)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* fix: guard Globe_.init() inside rAF to prevent double instantiation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.6-20260512 [version bump]

* feat(plugins): per-plugin deps, lazy tool loading, validation, shared discovery

Phase 3 — Plugin config validation + override warnings:
- New API/pluginValidation.js with validatePluginConfig() for tool, component,
  and backend manifests. Validates required fields (name, paths), object/string
  shape of paths, dependencies block (npm/python.pip/python.conda), and warns
  on unknown top-level fields.
- updateTools()/updateComponents() now skip invalid plugins and emit override
  warnings (matching what components already logged for tools).

Phase 2 — Shared discoverPlugins() utility:
- New API/pluginDiscovery.js consolidates the duplicated scanning logic from
  updateTools(), updateComponents(), and getBackendSetups(). Supports exact-
  name and substring container patterns, JSON/require/no-op loaders, and skips
  dot/underscore-prefixed dirs.
- updateTools.js and setups.js refactored on top of the shared helper.

Phase 1 — Per-plugin dependency declaration + build-time aggregation:
- Plugin config.json may now declare a 'dependencies' block (npm + python.pip +
  python.conda). validatePluginConfig() also validates this shape.
- New scripts/resolve-plugin-deps.js scans every tool/component/backend plugin
  and writes plugin-package.json, plugin-python-requirements.txt, and
  plugin-conda-deps…
)

* Fix Modal: remove close button, fix blur persistence, add fade animation

- Remove the Dialog.Close button (curved bottom-left border-radius)
- Drop Base UI Dialog wrapper entirely — it was fighting with the
  imperative Modal.set/remove API. Now uses simple divs with CSS
  transitions, matching the original jQuery modal behavior exactly.
- Blur management is now purely imperative via _applyBlur() called
  synchronously in set() and remove(). Removed async useEffect approach.
- Add 500ms CSS opacity fade-in/fade-out transition matching original.
- Closing state: Modal.remove() marks modal as closing (triggers
  opacity 0 transition), then removes from DOM after 500ms.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename Ancillary React components to .jsx, fix modal blur via Zustand store, fix IconButton forwardRef, add Help.finalize calls

- Rename Modal, ConfirmationModal, Help, ContextMenu, Compass, MapLogo from .js to .jsx
- Route modal blur through Zustand modalBlurCount instead of imperative DOM manipulation
- Remove conflicting jQuery blur animation in Layers_.js
- Wrap IconButton with React.forwardRef to fix Tooltip/Menu trigger warnings
- Add Help.finalize() calls to ChemistryTool, DrawTool, IsochroneTool
- Change aboutModalContent config type from 'markdown' to 'textarea'

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert aboutModalContent type back to 'markdown' — Maker.js already handles it

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Help.jsx: check res.ok on fetch, sanitize HTML with DOMPurify

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix ContextMenu WKT null guard, fix Modal blur timing during fade-out

- Add null check for feature in handleActionClick WKT placeholder handling
- Delay blur removal until after 500ms fade-out completes (blur stays in sync with backdrop opacity)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Reorganize folder structure: dissolve Ancillary, nest components

- Dissolve src/essence/Ancillary/ entirely
  - UI components → UserInterface_/components/ (Modal, ConfirmationModal, Help,
    ContextMenu, Compass, MapLogo, CursorInfo, Attributions, Login)
  - Layout chrome → UserInterface_/components/ (Description, Coordinates, Search,
    ScaleBar, ScaleBox)
  - Pure services → essence/services/ (DataShaders, LocalFilterer, QueryURL, Sprites)
  - Stylize.js → design-system/ (theme bridge alongside themeApplier)
  - Delete unused Swap.js

- Nest all components into own folders (ComponentName/ComponentName.ext pattern):
  - UserInterface_/components/: TopBar/, Toolbar/, ToolPanel/, SplitScreens/,
    Splitter/, BottomBar/, BottomElementPositioner/, Layout/, Panels/
  - design-system/components/: Button/, IconButton/, Dropdown/, Toggle/, Modal/,
    Tooltip/

- Update ~70+ import paths across codebase
- Build verified locally

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Modal.set() race condition and themeApplier CSS variable override issue

- Modal.set() onAddCallback: Replace 50ms setTimeout with MutationObserver
  that waits for the modal element to appear in DOM before firing callback.
  Prevents silent jQuery binding failures on slower devices.

- themeApplier: Use Proxy to read computed CSS custom properties (set by
  Stylize.js per-mission overrides) instead of hardcoded theme object values.
  Stylize.js now calls refreshThemeDOM() after setting CSS variables so
  inline styles reflect mission-specific color overrides.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Sync toggleTimeUI DOM state to Zustand store

toggleTimeUI() now calls setTimeUIActive() and setTimeUIExpanded()
so BottomFloatingBar visibility and BottomElementPositioner offsets
reflect actual TimeUI state.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore #toggleTimeUI element in Coordinates markup

The element was accidentally dropped during the folder restructure move.
TimeUI.js and DrawTool.js check $('#toggleTimeUI').hasClass('active')
to gate histogram rendering and time-filter toggle visibility.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix #toggleTimeUI: restore click handler, tippy, active class; remove redundant jQuery positioning

Restored pieces lost during folder restructure:
- Click handler in init() and off handler in remove()
- Tippy tooltip for the time toggle button
- display:none when time is not enabled
- $('#toggleTimeUI').toggleClass('active') so TimeUI.js can check it
- $('#CoordinatesDiv > #toggleTimeUI').remove() on mobile

Removed jQuery CSS positioning from toggleTimeUI() since
BottomElementPositioner now reactively handles all bottom-anchored
element offsets via the Zustand store.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix CurtainTool.destroy() using undefined ReactDOM.unmountComponentAtNode

Use the stored _reactRoot.unmount() instead, matching the React 18
createRoot pattern already used in make().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix tooltips, scale indicator position, modal blur, help close button

Tooltips:
- Reduce Base UI Tooltip delay from 600ms (default) to 200ms
- Restyle tooltip popup to match tippy blue theme (var(--color-c2))
- Add Tooltip wrappers to TopBar panel toggles (Viewer/Map/Globe)
- Wrap Toggle with forwardRef so Tooltip render prop can attach ref
- Remove title attrs that conflicted with custom tooltips

Scale indicator:
- Remove scalefactor-specific positioning from BottomElementPositioner
  (it moves naturally with .leaflet-bottom.leaflet-left container)
- Position scalefactor to the left of compass at same bottom level

Modal blur:
- Call _applyBlur() immediately when marking modal as closing
  so blur clears during fade-out instead of persisting 500ms

Help modal:
- Add close (X) button in title bar matching other modal patterns

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Remove dead CSS: delete tools.css, clean ~600 lines from mmgisUI.css and mmgis.css

- Delete tools.css entirely (both selectors #CurtainToolList and
  .searchToolSelect are unreferenced anywhere in the codebase)
- Remove from mmgisUI.css: .mmgisRadioBar3/4/Vertical (140 lines),
  .mmgispureselect (104 lines), blink/condemned_blink_effect (38 lines),
  .slidecontainer/.slider (41 lines), .ar_slider (91 lines),
  .verticalSlider (91 lines), .mmgisMultirange_elev (19 lines),
  .ui-corner-all/bottom/right/br (9 lines)
- Remove from mmgis.css: #nodeenv, empty #topBar{}, #topBarInfo,
  #topBarHelp, #topBarFullscreen, #toggleUI, #logoGoBack
- Keep #topBarLink (used in BottomBarReact.jsx), #webgl-error-message
  (used by vendored THREE.js)
- All selectors verified with repo-wide grep before removal

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI fixes: tooltips, splitter hover, mobile toolbar, color schemes

- Replace Base UI Tooltip with simple React portal tooltip (200ms delay,
  tippy-matching style) — fixes missing tooltips for toolbar/topbar/bottom buttons
- Add cursor + hover highlight to vertical splitters (was missing because
  module CSS didn't inherit global .splitterV styles)
- Add hover highlight to tool panel drag handle
- Remove mdi-drag-vertical icon from tool panel drag
- Add mobile toolbar horizontal layout via @media query overrides
- Add 4 new color schemes: High Contrast (a11y), Dark Mars, Dark Midnight,
  Light Warm (total: 10 themes)
- Previous fixes also included in working tree:
  - timeUI border moved to toolsWrapper border-bottom (conditional)
  - #toggleTimeUI button removed entirely
  - CoordinatesDiv: vertical centering, unified background, 12px right offset
  - barBottom padding-bottom: 8px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix assignment operator used instead of comparison in TimeControl.fina()

Pre-existing bug: `TimeControl.enabled = true` was assigning instead of
comparing. Changed to `TimeControl.enabled === true`.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restructure Configure page UI tab: add all themes, Custom mode, enableWhenField

- Added all 10 theme presets to dropdown (was missing Dark Mars, Dark Midnight,
  Light Warm, High Contrast)
- Added 'Custom' option: skips preset theme, uses only color picker values
- Moved Theming section directly under Rebranding
- Nested 'Custom Color Options' under Theming with subdescription
- Added enableWhenField support to Maker.js: disables color pickers unless
  theme is set to Custom
- Renamed color options with clearer names and improved descriptions:
  Primary → Surface Color, Secondary → Deep Background Color,
  Tertiary → Text Color, Body → Page Body Color, Highlight → Feature Highlight
- Stylize.js: skip setTheme() when theme is 'Custom'
- Rebuilt configure page

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert tooltips to tippy.js, fix dropdown menus, redesign About modal

1. Tooltip: Replaced custom React portal tooltip with tippy.js wrapper.
   Uses the existing tippy.js dependency and 'blue' theme for consistency.

2. Dropdown: Replaced Base UI Menu with native portal dropdown.
   Base UI's nested Menu.Trigger + BaseButton composition was swallowing
   click events, breaking userAvatar and menuBtn menus. New implementation
   uses simple state + createPortal with proper outside-click dismissal.

3. About modal: Professional redesign with centered MMGIS ASCII art header,
   proper GitHub SVG logo link, clean metadata section, centered link
   buttons, attributions section, and NASA-AMMOS footer.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore .mmgisHelpButton base styles lost during Help.css module migration

The global .mmgisHelpButton styles (yellow color, compact 18x18px sizing,
0.7 opacity) were removed when Help.css was converted to Help.module.css.
Since Help.getComponent() emits raw HTML strings for jQuery-rendered tool
headers, it cannot use CSS Module scoped classes. Restored the base styles
in mmgis.css alongside the related .mmgisToolHelpBtn definition.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix session logout regression, About modal refinements, High Contrast theme, Stylize.js, Default Tool config

- Login: skip session.regenerate() for token-based re-auth (useToken:true)
  so reloading the main page no longer invalidates the configure page session
- About modal: replace ASCII art with mmgis.png logo, rename Attributions to
  Map Layer Attributions, remove footer logo, link NASA-AMMOS to ammos.nasa.gov
- High Contrast theme: change accent from #ffff00 to #ffd700 (gold) for better
  contrast ratios against dark backgrounds
- Stylize.js: color overrides only apply when theme is Custom or unset,
  preventing preset themes from being clobbered by stale config values
- Restore Default Tool config section in tab-ui-config.json (accidentally
  removed during Theming section reorganization)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore defaulttooldropdown case handler in Maker.js

The case was accidentally removed during the Configure page UI tab
restructure (d7f96c50). Without it, the Default Tool dropdown in the
Configure page rendered as nothing despite the config referencing it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* High Contrast tooltip text, panel toggle styling, scale position swap

- High Contrast theme: tooltips now use black text on yellow background
  via --color-c2-text variable (white for all other themes)
- About modal links use var(--color-f) for consistent theme text color
- Panel toggle buttons: 11px uppercase with 600 weight for better
  visibility
- Mapping scale button moved to bottom-right of compass (was top-left)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Reposition Viewer and Globe panel buttons to top-right

- Viewer: dropdown selector at top-right edge, OSD buttons stacked
  vertically below it; settings panel opens to the left
- Globe: home, exaggerate, observe, walk, link controls moved from
  TopLeft to TopRight corner via addControl 4th arg
- Style consistency: OSD buttons and LithoSphere controls now match
  Leaflet zoom controls (var(--color-a) bg, var(--color-f) text,
  var(--color-mmgis) hover, 30px size, 3px border-radius)
- Viewer settings sliders use var(--color-a3) instead of hardcoded
  #444444
- Az/el indicator stays at bottom center (exception per design)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Consistent modal theming + session security fix

Modal theming:
- All modals now share consistent styling: backdrop-filter blur, semi-transparent
  background via --color-a-rgb, 10px border-radius, header divider line, box-shadow
- Updated: loginModal, Help, ConfirmationModal, Settings, Hotkeys, About modals
- Tool panel backgrounds changed from opaque var(--color-k) to transparent so the
  ToolPanel's existing backdrop-filter effect shows through
- Legend tool header updated to match consistent 44px height with divider
- applyTheme.js now auto-derives --color-a-rgb from theme's --color-a hex value
- Modal service wrapper gets backdrop-filter: blur(12px)

Session security (Devin Review fix):
- Token re-auth now calls req.session.regenerate() with data preservation to
  prevent session fixation while maintaining multi-tab compatibility
- Token is rotated via crypto.randomBytes on every re-auth (was being reused)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI fixes: viewer settings, button sizing, menu contrast, coords, login header

1. Viewer OSD settings moved to top of button stack, panel opens downward
2. Overlay buttons consistent 30x30px (OSD line-height fix, home button)
3. Menu/icon contrast improved: Dropdown items and IconButtons use --color-a5
   (was --color-a3) with --color-f on hover for better dark theme legibility
4. CoordinatesDiv fixed to 30px height, pickLngLat button centered
5. Login modal now has a header bar with 'Log In' title and close X button;
   title toggles to 'Sign Up' when switching modes

Also reverts session regeneration for token re-auth (Devin Review feedback):
token-based re-auth now refreshes session data in-place without regeneration
or token rotation, preserving multi-tab compatibility.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI polish: nav popover, sep tools, compass, zoom controls, status indicator, toast

1. Description nav popover: added z-index:9000 so menu appears above map panels
2. Separated tools: default color changed from accent to --color-f; fixed CSS
   selector from .toolButtonSep to .toolSep to match actual class names
3. Compass + mapping scale shifted left by 30px for better positioning
4. Map zoom/home controls: use --color-f instead of accent --color-c to reduce
   visual prominence; hover still highlights with accent color
5. Status indicators (reload/ws disconnect/layer update) moved from Leaflet
   control to TopBar with soft pulsing fade animation and tooltip on hover
6. WebSocket retry toast: rounded corners, glass background with backdrop-filter,
   border-left accent for failure state instead of solid red background

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix 14 tool UI issues: headers, backgrounds, layout, functional bugs

Header alignment:
- LayersTool: align-items center on #filterLayers, gap between right icons
- InfoTool: align-items center on #infoToolHeader, 44px height
- ViewshedTool: align-items center, restructured header with left/right divs
- IsochroneTool: restructured flat header into nested mmgisToolHeader pattern
- ShadeTool: align-items center on #vstHeader children

Icon spacing:
- LayersTool: increased right margin to 28px + gap 2px
- ViewshedTool: #vstNew padding-right 30px (clear of close button)
- IsochroneTool: #iscNew padding-right 30px

Missing components:
- SitesTool: added Help import + help icon via mmgisToolHeader pattern
- AnimationTool: added full mmgisToolHeader with title and help icon

Background fixes:
- InfoTool: changed toolsContainer background from transparent to var(--color-a)
- DrawTool: changed toolsContainer background from transparent to var(--color-a)

Layout fixes:
- DrawTool: #drawToolContents top 81px, height calc(100%-81px), #drawToolNav margin-right 0
- MeasureTool: removed padding-left:0 override from mmgisToolHeader child selector

Functional fixes:
- InfoTool: updated jQuery selectors from #InfoTool to #toolButtonInfo (React toolbar IDs changed)
- CurtainTool: deferred OpenSeadragon init with requestAnimationFrame (React 18 async render)
- CurtainTool: curtainToolBar justify-content flex-end (icons at bottom)

Security:
- TopBar StatusIndicator: escape HTML in layer names to prevent XSS via addLayerQueue

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix mapToolBar pointer events, login padding, default tool, About modal order

1. mapToolBar: set pointer-events:none on both #mapToolBar and direct children
   so clicks pass through to the map; leaf elements still get auto via
   .childpointerevents rule
2. #loginModalBody: padding changed to 40px 0px 0px
3. Default tool: deferred click to requestAnimationFrame so React toolbar
   has rendered before getElementById runs
4. About modal: moved mainInfoModalCustom to right below mainInfoModalHero

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix tool headers to 40px, ViewshedTool subheader, AnimationTool header, InfoTool close btn, statusIndicator position

1. All tool panel headers: changed from 44px to exactly 40px height
   - Global .mmgisToolHeader and .mmgisToolTitle in mmgis.css
   - InfoTool.css, ViewshedTool.css, IsochroneTool.css, ShadeTool.css

2. LayersTool #filterLayers: height 40px, .right > div height unset,
   .right margin-right 30px, .right > div margin 0px 3px

3. ViewshedTool: restructured header — title+help in mmgisToolHeader row,
   vstToggleAll (left) and vstNew (right) on a new #vstSubHeader row below

4. AnimationTool: removed old #animationToolHeader CSS (padding 15px 20px,
   white background, 18px font), now uses standard mmgisToolHeader class.
   Fixed color from var(--color-a) (background) to var(--color-f) (text)

5. InfoTool close X: re-inject close button after use() rebuilds content
   via TC_.injectCloseButton() (toolsContainer.empty() was removing it)

6. StatusIndicator: moved to left of topBarTitle in JSX render order.
   Added align-items:center to #topBarMain for vertical alignment.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Remove title attr from StatusIndicator (conflicts with tippy tooltip)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TimeUI dropdown z-index above tool panel, reposition toasts to top-center

1. TimeUI dropdowns: added z-index 10000 to all dropy content ul elements
   so they render above the vertical tool panel (z-index 1400). Also set
   timeUIDock to position:relative with z-index 10000 and overflow:visible.

2. Toast notifications: repositioned #toast-container from bottom-right to
   top-center just below the topbar (top: 44px, centered with transform).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix StatusIndicator spacing, CurtainTool close btn, header title font consistency

1. StatusIndicator: use display:none/flex instead of opacity:0/1 so it
   takes no space when there's no active status indicator.

2. CurtainTool: added close X button at top of curtainToolBar (matching
   MeasureTool pattern) with flex spacer pushing other buttons to bottom.

3. Header title font consistency: InfoTool, ShadeTool, CurtainTool titles
   now match mmgisToolTitle standard (font-weight:600, padding-left:10px,
   height:40px for CurtainTool which was 34px).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Legend empty state, scalefactor position, sep-tool-header unbold

1. Legend: show 'No active layers with legends' when no legend items
   are present. Also fixed container height calc(100% - 40px).

2. Mapping Scale (.leaflet-control-scalefactor): shifted 10px right
   (left 26→36px) and 1px down (bottom 30→29px).

3. sep-tool-header span: font-weight changed from 600 to 400.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Guard Legend empty state message to only show when panel is active

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TimeUI dropdown covered by toolPanel: remove splitscreens stacking context

#splitscreens had z-index:1 which created a stacking context, confining
its children (including bottomFloatingBar at z-index:1500) to that context.
Since ToolPanel (z-index:1400) was a sibling outside splitscreens, it
painted above all splitscreens children regardless of their internal
z-index values.

Fix: change #splitscreens z-index from 1 to auto so it no longer creates
a stacking context. Now bottomFloatingBar (1500) participates in the
same stacking context as ToolPanel (1400), and 1500 > 1400 means the
TimeUI dropdown correctly paints above the tool panel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Migrate ~69 CursorInfo toast-like calls to proper Toast component

- Replace CursorInfo.update() toast-like calls with Toast.info/success/warning/error
- Preserve message colors: blue→info, green→success, yellow→warning, red→error
- Keep 12 legitimate cursor-following CursorInfo calls unchanged
- Files migrated: DrawTool.js, DrawTool_Files.js, DrawTool_FileModal.js,
  DrawTool_Templater.js, DrawTool_SetOperations.js, DrawTool_Drawing.js,
  DrawTool_Editing.js, DrawTool_Shapes.js, LayersTool.js, ShadeTool.js,
  chemistrychart.js
- Fix Devin Review: Change misleading 'Bad token' to 'Login failed' in users.js
- Normalize line endings (CRLF→LF) in affected files

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Toast.js missing from git, CoordinatesDiv z-index, Legend duplicate ID, topBar padding

- Add Toast.js to version control (was untracked, causing webpack module error)
- Bump CoordinatesDiv z-index from 20 to 1001 (was hidden behind splitscreens
  children after z-index:auto change)
- Fix Legend duplicate ID: separated tool icon was #LegendTool, same as content
  container div, causing empty message to appear in button instead of panel
- Add hasStatus class to #topBarMain when statusIndicator is active, setting
  #topBarTitleName padding-left to 0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Legend empty message: scope selector to content container via targetId

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Move Toast.js to design-system, fix --color-a3 text contrast, update AGENTS.md

- Move Toast.js from UserInterface_/components/Toast/ to design-system/components/Toast/
  (generic component belongs in design-system, not MMGIS-specific UserInterface_)
- Update all 11 Toast import paths to new location
- Bump --color-a3 in 5 dark themes to pass WCAG AA 4.5:1 contrast for text:
  Dark Default #747c81→#81888d, Dark Blue #64748b→#738399,
  Dark Warm #8b7a5e→#908064, Dark Mars #8a6a60→#98796f,
  Dark Midnight #606088→#7a7a9e
- Update AGENTS.md: document design-system/ vs UserInterface_/ distinction
  in project structure and Key Directories

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix topBarTitleName padding override: increase specificity and add !important

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix IdentifierTool deactivation: update icon ID reference in separateFromMMWebGIS

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Hide toolPanelDrag when no tool is open

- Add setToolPanelDragVisible(false) to closeActiveTool() (was only in makeTool toggle-off path)
- Also guard drag handle display on isOpen (toolPanelWidth > 0) as safety net

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add hover effect to MMGIS logo (opacity + brightness transition)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix MMGIS logo hover: keep full opacity, use subtle background highlight instead

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Selective tile fade: fade on pan/zoom, instant on refresh/reload

- Remove blanket _fadeAnimated toggle from toggleTimeUI (was killing fade
  for all tiles while TimeUI was open)
- Monkey-patch GridLayer.redraw, TileLayer.setUrl, and GridLayer._tileReady
  to suppress fade via a transient _suppressTileFade map flag
- Set _suppressTileFade in reloadTimeLayers for time-driven reloads
- Flag auto-clears after 300ms so pan/zoom tiles still get the nice fade
- Install pbf dependency (required by CesiumMVTLayer from #942 merge)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Per-layer fade control: time-enabled + shade/viewshed layers never fade

- Replace transient map-level _suppressTileFade with per-layer _noFade flag
- Patch GridLayer._tileReady to check _noFade on the layer instance
- Set _noFade on time-enabled tile layers and data/GL layers at creation
- Set _noFade on Shade and Viewshed tool GL layers
- Non-time-enabled base imagery tiles still fade normally on pan/zoom

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile UI improvements — move hamburger to right menu, show panel toggles, isMobile-driven toolbar layout, desktop-matching scalebar/compass

- Remove left hamburger menu (#topBarMenu), move BottomBar items into
  top-right kebab dropdown menu for both mobile and desktop
- Show panel toggles (Viewer/Map/Globe) and account/login UI in mobile
  topbar's #topBarRight
- Move toolbar horizontal layout CSS from @media breakpoints to
  UserInterfaceMobile_.css (loaded only when isMobile flag is true)
- Remove #mapTopBar @media rule from mmgis.css, add to mobile CSS
- Remove mobile-only simplified scalebar rendering; use full desktop
  scalebar with both large and small axes on all viewports
- Remove display:none on .leaflet-control-scalefactor in mobile CSS
- Remove #loginDiv display:none from mobile CSS (React overlay handles it)
- Simplify BottomBarReact container styles (no more absolute positioning)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260430 [version bump]

* fix: mobile toolbar 40px height, bottomFloatingBar flush, timeUI toggle, scalebar position, hide hotkeys

- Toolbar height 40px, toolButton width 40px, no border-bottom
- toolcontroller_incdiv: no padding-bottom, overflow-y hidden
- bottomFloatingBar: no border-radius, left/right/bottom = 0
- Add MobileTimeUIToggle button on far right of toolbar
- Hide Keyboard Shortcuts from kebab menu on mobile
- Fix scalebar positioning (remove top:48px override in UserInterfaceBridge)
- Set mobileTopSize/topSize to 40 (splitscreens top = 40px, not 50px)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile topBar padding-left 34px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: update MobileCoordButton topBar paddingLeft from 80px to 34px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: MobileTimeUIToggle — inline toggle logic, float right, hide from settings on mobile

- Replace broken Coordinates.toggleTimeUI() call with direct jQuery/store toggle
- Float time button right in toolbar
- Hide Time UI toggle from settings modal on mobile (toolbar has it)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: push scalebar/compass/scale up 40px on mobile, keep #timeUI in DOM

- BottomElementPositioner: position mapToolBar, leaflet-bottom-left/right
  40px above bottom on mobile (above toolbar)
- Stop removing #timeUI from DOM on mobile so MobileTimeUIToggle works

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — only show endtime, always expanded

- Hide #mmgisTimeUIStartWrapper and StartWrapperFake on mobile via CSS
- Force expanded state (addClass expanded + show) when toggling TimeUI on
- CSS ensures #timeUI.active always shows expanded content on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI opens in tool panel with header, end time, expanded rows

- MobileTimeUIToggle now opens/closes the tool panel via ToolController_
- Closes any active tool before showing TimeUI
- Forces expanded state when opening
- CSS hides start time inputs, positions expanded content properly
- Overrides absolute positioning of expanded content for tool panel flow

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: rewrite separated tools system from jQuery to React components

- Add separatedToolsList/activeSeparatedTools state to Zustand uiStore
- Rewrite SeparatedTools.jsx with glassmorphism panels, CSS Module styling
- Replace SepToolsContainer (setInterval hack) with SepToolButton/SepToolsSection
- Remove ~170 lines of jQuery DOM construction from ToolController_.js
- Fix hardcoded rgba(26,26,27,0.88) to theme-aware var(--color-a-rgb)
- Remove separated tool entries from themeApplier.js
- Remove separated tool overrides from FloatingElements.css
- Move Legend CSS overrides from Toolbar.module.css to SeparatedTools.module.css
- Remove jQuery active-state manipulation from IdentifierTool.js
- Add store sync in Map_.js displayOnStart logic
- Preserve all DOM IDs for backward compatibility (mmgisAPI, tool make())

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260501 [version bump]

* fix: TimeUI mobile checks — use Zustand store instead of L_.UserInterface_

L_.UserInterface_ is null when TimeUI.init() runs (TimeControl.init is called
before L_.link sets UserInterface_). All 16 isMobile checks now read from
useUIStore.getState().isMobile which is set at startup.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: move displayOnStart logic from Map_.js to ToolController_.finalizeTools()

- Map_ no longer references specific tools (LegendTool)
- displayOnStart is now handled generically for all separated tools
- Added DOM element polling (tryMake) to handle React render timing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* revert: remove all TimeUI-related mobile changes

Reverts TimeUI.js and BottomBar.js to development base.
Restores #timeUI DOM removal in UserInterfaceBridge.fina().
Removes MobileTimeUIToggle component from Toolbar.jsx.
Removes TimeUI mobile CSS overrides from UserInterfaceMobile_.css.
Non-TimeUI refinements (toolbar height, scalebar positioning, etc.) preserved.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* simplify: remove DOM polling, use simple setTimeout(0) for auto-open

LegendTool handles its own content lifecycle via subscribeOnLayerToggle.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: mobile TimeUI — fix isMobile detection, staging container, toolbar toggle

- TimeUI.js: import useUIStore and replace all 16 L_.UserInterface_?.isMobile
  checks with useUIStore.getState().isMobile (L_.UserInterface_ is null when
  TimeUI.init() runs, so mobile conditionals were dead code)
- TimeUI.js: stage mobile #timeUI in hidden #timeUIMobileStaging instead of
  placing directly in #tools (which gets cleared by other tools)
- UserInterfaceBridge.js: stop removing #timeUI from DOM on mobile
- Toolbar.jsx: add MobileTimeUIToggle that moves #timeUI between staging and
  #tools, opens/closes tool panel via ToolController_
- BottomBar.js: hide TimeUI toggle from settings modal on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: rescue #timeUI back to staging when another tool opens

Subscribe to activeToolName changes — when a tool becomes active while
TimeUI is showing, move #timeUI back to #timeUIMobileStaging before
the new tool's make() clears #tools.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove separatedTool/justification config toggles, fix review issues

- Remove separatedTool checkbox and justification dropdown from Legend
  and Identifier config.json (these are always separated, not configurable)
- Remove justification property/code from LegendTool.js, IdentifierTool.js
- Simplify Globe_.js separated tool count (no justification filter)
- Remove justification from Reference-Mission config blueprint
- Update LegendTool help docs and Legend.md documentation
- Add --color-a-rgb fallback (29,31,32) in SeparatedTools.module.css
- Add display:none !important to .panelIdentifier to prevent 12px gap
- Update e2e test comment

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: circular import in TimeUI.js, toolbar/bottomFloatingBar position sync

- TimeUI.js: replace top-level useUIStore import with lazy _getUIStore()
  accessor to avoid 'Cannot access useUIStore before initialization'
  circular import error at _remakeTimeSlider
- SplitScreens.jsx: skip #timeUI reparenting observer on mobile (mobile
  uses MobileTimeUIToggle to manage #timeUI placement in #tools)
- BottomElementPositioner.jsx: unify mobile transition to 0.3s (matches
  toolsWrapper and toolbar), guard pxIsTools against undefined
- Toolbar.jsx: align toolbar transition to 0.3s ease-out

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* LegendTool fix empty message

* chore: remove separated tools offset logic from Globe_.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: skip _makeHistogram on mobile (no timeline slider, timestamps unset)

_makeHistogram renders inside the timeline slider which doesn't exist
on mobile. Without it, _timelineStartTimestamp is NaN, causing
'Invalid time value' RangeError at toISOString().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — populate expanded rows, fix Invalid date, fix panel height

- TimeUI.js attachEvents: use _initialStart/_initialEnd on mobile (same
  as desktop) instead of L_.TimeControl_ which isn't set yet at init time.
  Fixes 'Invalid date' in start/end time inputs.
- TimeUI.js fina: set expanded=true on mobile and call _populateExpandedRows()
  so year/month/day/hour rows actually render. Removed position:absolute and
  pointer-events:none overrides.
- Toolbar.jsx: set tool panel height to 217px (TimeUI.height) instead of
  45% viewport — matches actual TimeUI content height.
- UserInterfaceMobile_.css: expanded content flows naturally (position:relative),
  hide start time inputs, allow overflow scroll, flex-wrap topbar.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI justify-content center, restore toolbar border-bottom

- Add justify-content: center to #mmgisTimeUIMain on mobile
- Remove border-bottom: none override so toolbar keeps its default border

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI overflow hidden, scalebar/compass fixed at 40px offset

- #timeUI overflow-y: hidden (was auto, causing 2px scroll)
- Scalebar/compass/map controls stay at fixed 40px offset (above toolbar)
  regardless of tool panel state — no longer shift up by pxIsTools

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Implement multi-tier knowledge architecture

- Restructure AGENTS.md from 745 lines to 106 lines (Tier 1: essential context)
- Create knowledge/ directory with 30+ wiki-style documentation files (Tier 2: deep knowledge)
- Create knowledge/reference/ with 8 detailed reference files (Tier 3: lookup material)
- Move AI-GETTING-STARTED.md and AI-DEVELOPMENT.md to knowledge/
- Update all file references in .specify/templates and blueprints
- Create knowledge/README.md as the full knowledge base index
- Create knowledge/reference/README.md as reference material index

Three-tier knowledge discovery system:
  Tier 1: AGENTS.md (~106 lines) - scannable in <2 minutes
  Tier 2: knowledge/*.md - deep knowledge on architecture, tools, APIs, DB, infra
  Tier 3: knowledge/reference/*.md - coding conventions, API reference, troubleshooting

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: mobile toolbar active button style matches desktop, fix icon alignment

- All mobile toolbar buttons (ToolButton, MobileCoordButton, MobileTimeUIToggle)
  now use display:flex with align-items/justify-content center for proper
  vertical icon centering
- MobileCoordButton: changed 'active' class to 'toolButtonActive' to match
  the global CSS active style (color-mmgis + color-i background)
- Removed inline color overrides so CSS .toolButtonActive takes effect

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add Devin knowledge notes from past MMGIS sessions

Include curated lessons learned from past Devin sessions:
- CI/CD: ignore build-arm64/amd64 failures, focus on required checks
- Child sessions: no separate PRs when consolidating
- ENV triple-update rule (.env, sample.env, ENVs.md)
- Error handling: use logger with infrastructure_error for fatal startup errors
- Path traversal security: stay within /Missions, handle subpath serving
- Database initialization architecture and migration patterns
- API authentication behavior across AUTH modes
- Auto-generated MMGIS concept index

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar active button style, icon alignment, tool deactivation

- Active toolbar buttons get desktop-matching margin (1px 0) and
  border-radius (8px) via .toolButton.toolButtonActive CSS rule
- Removed line-height: 40px from .toolButton (flex centering handles
  vertical alignment, line-height was pushing icons up)
- MobileCoordButton now watches activeToolName store and deactivates
  when another tool opens (fixes coords staying active)
- MobileTimeUIToggle sets activeToolName='MobileTimeUI' when opening
  so coords/other buttons can detect it and deactivate
- MobileTimeUIToggle clears activeToolName when closing
- Both custom buttons skip self-deactivation via name check

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix cross-references: convert backtick refs to markdown links, add Devin knowledge notes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon height 40px, button margins for active padding

- #toolbar .toolButton i: height 40px fixes icon vertical alignment
- #toolbar .toolButton: margin 0 2px gives spacing between buttons
- #toolbar .toolButton.toolButtonActive: margin 1px 2px so active
  background has visual padding around the icon

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename knowledge/ to .knowledge/ for consistency with .specify/ convention

Dot-prefix signals agent infrastructure (not source code), consistent with
.specify/, .github/, .vscode/ conventions. All cross-references updated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon line-height 40px, active button padding via height

- Coord and TimeUI button <i> icons get line-height: 40px
- Active buttons: height 34px (vs 40px toolbar) creates visual padding
  around the active background, centered by flex align-items
- Buttons get margin: 0 1px for horizontal spacing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix broken cross-reference: 06.2 -> 06.1-configure-rest-api.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: close active tool + cancel deferred cleanup in MobileCoordButton/TimeUI

- MobileCoordButton: call closeActiveTool() before opening, destroy
  _pendingCloseTool if set, increment _closeSeq to cancel deferred
  tools.innerHTML clear
- MobileTimeUIToggle: same _pendingCloseTool + _closeSeq fix after
  closeActiveTool() to prevent 420ms deferred cleanup from wiping
  #timeUI after it's placed in #tools
- Removed redundant closeActiveTool() from MobileCoordButton close path
  (was being called after destroy, not needed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: active mobile toolbar buttons 34x34px (square)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Drastically compress .knowledge/ — keep only unique agent content

Remove 33 wiki files that duplicate docs/pages/ content.
Remove 9 reference/ files derivable from source code.
Keep only 5 files (down from 46):
  - AI-GETTING-STARTED.md (agent setup walkthrough)
  - AI-DEVELOPMENT.md (spec-kit workflow)
  - conventions-and-gotchas.md (naming, code style, common issues)
  - 12-devin-knowledge-notes.md (CI, auth, DB init, security gotchas)
  - README.md (index pointing to docs/pages/ for everything else)

Principle: don't duplicate docs/ — only keep what's uniquely agent-optimized.
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename to knowledge-notes.md, remove Devin branding and fork-specific CI section

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: hide mmgis-map-logo on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore Database Safety Rules for AI Agents section in AGENTS.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: shift compass and map scale 6px to the right (both mobile and desktop)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add back Important Instructions, code pattern templates, and detailed project structure

- Important Instructions in AGENTS.md: MCP tools, hot-reload, Reference Mission
- .knowledge/code-patterns.md: full directory tree with key directory annotations,
  plus copy-paste templates for Express routes, Sequelize models, Tool plugins,
  and WebSocket handlers

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Update project structure trees to reflect current filesystem

Add missing directories: tests/, .knowledge/, .specify/, .github/, views/,
private/, spice/, build/, examples/, scripts/middleware.js.
Both abbreviated (AGENTS.md) and detailed (.knowledge/code-patterns.md) trees
now match the actual repo layout.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.30-20260501 [version bump]

* Add Layers_.js to project structure (key singleton L_)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix project structure: correct API layout, frontend modules, code templates

API/Backend/ uses feature-domain modules (Draw/, Users/, Config/, etc.)
with setup.js + routes/ + models/ per feature — not APIs/ or Databases/.
Frontend essence/ has Components/, Helpers/, LandingPage/, mmgisAPI/,
services/ — not Ancillary/. Basics/ includes all singletons (Globe_,
Formulae_, ToolController_, Viewer_, ComponentController_, Test_).
Code templates updated to match actual patterns (setup.js, module.exports).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove test infrastructure (Test_ module, testModules, DrawTool.test)

- Delete src/essence/Basics/Test_/ directory
- Delete src/essence/Tools/Draw/DrawTool.test.js
- Remove Test_ import and Shift+T keydown handler from essence.js
- Remove tests key from Draw tool config.json
- Remove testModules generation logic from API/updateTools.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.31-20260501 [version bump]

* style: move Cesium link button to top-right and match Leaflet zoom button styling

- Change control container from top-left to top-right positioning
- Update button size from 26px to 30px to match Leaflet zoom controls
- Use CSS variables (--color-a, --color-f, --color-mmgis) instead of hardcoded colors
- Add border-radius and box-shadow matching Leaflet control appearance
- Update hover/inactive states to use themed colors

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: anchor map logo to viewport instead of Leaflet map panel

- Change MapLogo parent from .leaflet-bottom.leaflet-right to #main-container
- Switch CSS position from absolute to fixed for viewport anchoring
- Add explicit bottom-offset positioning in BottomElementPositioner (desktop)
- Add explicit bottom-offset positioning in BottomElementPositioner (mobile)
- Logo stays at viewport right edge regardless of open side panels
- Retains smooth bottom offset transitions when bottom bar appears

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: remove references to deleted test infrastructure (Test_, DrawTool.test)

- Remove Test_/ from project structure in .knowledge/code-patterns.md
- Remove DrawTool.test.js references from specs/006 spec, plan, and tasks
- Remove Draw Tool Testing section from tasks.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.32-20260501 [version bump]

* fix: append logo to document.body to avoid filter containing block

#main-container has a CSS filter property which creates a new containing
block per the CSS spec, causing position:fixed to behave like absolute.
Appending to document.body ensures true viewport-fixed positioning.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: prevent mobile topBarTitleName text wrapping by replacing max-width with white-space: nowrap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.33-20260501 [version bump]

* chore: bump version to 5.0.0 and update changelog

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(ui): move Screenshot/Fullscreen to BottomBar, About to TopBar kebab

TopBar kebab menu now contains only Keyboard Shortcuts, Settings, and About
(About now shows on both desktop and mobile).

BottomBarReact now renders Screenshot, Fullscreen, and Copy Link buttons
(top to bottom) following the same IconButton + Tooltip pattern. The
About button has been removed from BottomBarReact.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(mobile): enforce exclusive panel toggling on mobile in TopBar

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* style: reposition LithoSphere globe controls to match Leaflet/Cesium theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(topbar): hide Viewer/Globe toggles based on configured panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): reorder buttons (Copy Link, Screenshot, Fullscreen) and unify size

Reorder the BottomBarReact buttons top-to-bottom to: Copy Link, Screenshot,
Fullscreen.

Move the 24x24 button sizing from the #topBarLink id selector in mmgis.css
into the .barButton class in BottomBarReact.module.css so all three buttons
share the same compact size as the original Copy Link button. Drop the now
redundant #topBarLink rule.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): increase padding-bottom to 12px and button margin to 3px 0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: rearrange globe controls — compass top-right circular, nav row, vertical column, panels open left

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* chore: bump version to 5.0.2-20260505 [version bump]

* style: anchor observe settings panel right:34px and float nav hover panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(theming): add 5 new themes, --color-shadow variable, and configure ThemePreview

- Add Dark Terra, Dark Nebula, Dark Lunar, Dark Supernova, Light Botanical themes
- Add --color-shadow CSS variable to every theme + :root fallback
- Replace hardcoded rgba shadow colors with var(--color-shadow) in TopBar,
  Toolbar, SeparatedTools, ToolPanel, FloatingElements, Dropdown, Modal,
  and SplitScreens
- Add Custom shadowcolor color picker in tab-ui-config and apply it via Stylize
- Add ThemePreview component (configure/src) wired through Maker.js as
  a new 'themepreview' row type so the configure UI shows a live mini
  mockup of the selected theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* fix(configure/ThemePreview): tighten top spacing and live-preview Custom theme

- Pull the preview up by 12px so the gap below the theme dropdown is tighter.
- Read the Custom color pickers (look.primarycolor / secondarycolor /
  tertiarycolor / accentcolor / shadowcolor / topbarcolor / toolbarcolor /
  mapcolor) from the configuration and overlay them on Dark Default so
  the preview reflects Custom theme edits live, matching Stylize.js's
  runtime behavior.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260505 [version bump]

* feat(themes): add Dark Heliosphere, Dark Monokai, and Light Solarized

- Dark Heliosphere: deep night purple surface with corona-orange accent.
- Dark Monokai: warm graphite surface with lime accent (Monokai-inspired).
- Light Solarized: classic solarized base3/base02 with blue accent.

Mirror added to configure/src/themes/themes.js for the ThemePreview, and
the three names appended to the Color Theme dropdown options.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(coordinates): respect time.initiallyOpen when live deep-link is set

* chore: bump version to 5.0.3-20260505 [version bump]

* refactor(theming): remove Custom theme + per-field color overrides

- Drop the 'Custom' option from the Color Theme dropdown.
- Remove all Custom Color Options (look.primarycolor, .secondarycolor,
  .tertiarycolor, .accentcolor, .bodycolor, .topbarcolor, .toolbarcolor,
  .mapcolor, .hightlightcolor, .shadowcolor) from tab-ui-config.json.
- Strip the matching DOM/CSS-variable override block from Stylize.js;
  Stylize now just applies the selected preset theme (and the page logo).
- Drop the empty bodycolor/topbarcolor/toolbarcolor/mapcolor/shadowcolor
  defaults from API/templates/config_template.js.
- Simplify ThemePreview to render the selected preset directly — no
  Custom branch, no overlay logic.

Preset themes cover all the looks we want and keep the configure surface
much smaller.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(time-ui): round corners on TimeUI shell, action wrappers, mode dropdown

- #timeUI: 10px border-radius on the outer time control bar.
- #mmgisTimeUIActionsLeft / #mmgisTimeUIActionsRight: 10px border-radius
  so the action clusters sit as rounded chips.
- #mmgisTimeUIActionsRight > div (excluding #mmgisTimeUIPresent): 10px
  border-radius on each action button so they match the wrapper.
- #mmgisTimeUIModeDropdown: 40px height + 10px border-radius to align
  with the rest of the bar; clear the dropy default border-color so the
  rounded edge isn't outlined.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260505 [version bump]

* feat(configure): mark light themes as (experimental) in dropdown label

Light themes still have outstanding contrast issues, so flag them in the
Color Theme dropdown without changing the saved value.

- Maker dropdown now accepts options as either a plain string (current
  behavior) or { value, label } so the rendered label can differ from
  the persisted value.
- tab-ui-config switches the six light themes to { value, label } form
  with '(experimental)' appended to the label only. Existing mission
  configs that already saved 'Light Default' etc. continue to match.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix timeUI border radius

* fix(mobile): rescue #timeUI before tool make() destroys it

Clicking Layers -> Time -> Layers -> Time on mobile caused the bottom
panel to render LayersTool content with TimeUI height. The #timeUI DOM
element was destroyed when LayersTool.make() called $('#tools').empty(),
before the async React useEffect in MobileTimeUIToggle could rescue it
to its staging container.

- ToolController_.makeTool: synchronously move #timeUI from #tools back
  to #timeUIMobileStaging (and reset TimeUI store flags) on mobile,
  before invoking the new tool's make().
- MobileTimeUIToggle.handleClick: defensive fallback that re-initializes
  TimeUI if #timeUI no longer exists when the toggle is activated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): move re-initialized #timeUI from staging into #tools

TimeUI.init() on mobile appends the new #timeUI to the hidden
#timeUIMobileStaging container, so the fallback branch must also move
it into #tools — otherwise the user sees an empty tool panel after
the destroyed-element recovery path.

Caught by Devin Review.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): preserve #timeUI when Coordinates tool empties #tools

On mobile, opening or closing the Coordinates tool runs
$('#tools').empty() inside interfaceWithMMWebGIS / separateFromMMWebGIS.
After the previous PR commits, clicking Coordinates -> Time still left
the bottom panel empty because:

- Coordinates.make() empties #tools while #timeUI is in staging (fine
  on its own), but the Coordinates teardown that fires after the user
  switches to the Time toggle (via MobileCoordButton's useEffect on
  activeToolName change) calls Coordinates.destroy() ->
  separateFromMMWebGIS(), which empties #tools wholesale and destroys
  the freshly-placed #timeUI.

Add a rescueMobileTimeUI() helper that moves #timeUI from #tools back
to #timeUIMobileStaging before each tools.empty() call in Coordinates,
mirroring the rescue already done in ToolController_.makeTool().

Coordinates -> Time now correctly shows the TimeUI.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): harden TimeUI fallback recovery (call fina(), de-dupe popovers)

Devin Review correctly flagged that the safety-net path in
MobileTimeUIToggle.handleClick was producing a partially-broken TimeUI
when it fired:

- TimeUI.init() unconditionally appends a new #timeUIPlayPopover_global
  to <body>, so a second init() left two elements with the same id.
- TimeUI.init() alone does not wire up date pickers or per-button click
  handlers — that's TimeUI.fina()'s job. Without fina(), the recovered
  TimeUI rendered visually but Play / Previous / Next / Fit / Follow /
  Present / Expand were all dead.

Before re-initializing, remove the stale #timeUIPlayPopover_global and
#timeUIQuickSelectPopover_global divs to avoid duplicate ids. After the
new #timeUI is moved into #tools, call TimeUI.fina() to populate the
date pickers, attach the button click handlers, build the histogram,
and populate the expanded mobile rows.

Some delegated body/document handlers in attachEvents() will still be
duplicated on this path; that is acceptable for a degraded recovery
that should never run in practice now that the primary rescues in
ToolController_.makeTool() and Coordinates.js cover all known paths.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260505 [version bump]

* fix(mobile): Coordinates teardown only removes its own DOM

The previous Coordinates fix was racing with itself: after the Time
toggle synchronously moved #timeUI into #tools, MobileCoordButton's
useEffect (triggered by the activeToolName change) ran on the next
React tick and called L_.Coordinates.destroy(). That called
separateFromMMWebGIS(), whose rescue moved #timeUI right back into the
hidden staging div before tools.empty() — so the bottom panel ended up
empty even though the time toggle was 'active'.

Make separateFromMMWebGIS selective: only remove the
Coordinates-specific DOM (#coordUIHeader and #CoordinatesDiv) instead
of wiping all of #tools. Any other content already in #tools (e.g.
#timeUI placed there by the Time toggle) is left alone.

interfaceWithMMWebGIS still keeps the rescue + tools.empty() pattern
on the open path so Coordinates always starts from a clean panel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Bump DrawTool Temporal Drawings upward

* chore: bump version to 5.0.6-20260505 [version bump]

* chore: reset version to 5.0.0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test(e2e): fix 9 pre-existing failures (test-only changes)

- mmgis-api.spec.js: add form-fill login under AUTH=local; serialize
  describe to avoid concurrent-login race in the session store
- coordinates.spec.js: TimeUI toggle was moved from the coordinates bar
  to the Settings modal; navigate via topbar kebab menu and assert the
  checkbox is rendered
- widgets.spec.js: target .leaflet-control-zoom-in/-out specifically;
  the bare .leaflet-control-zoom class is also used by the home/reset
  control, so the original assertion was always false
- sites.spec.js: scope panel selector to #toolPanel; both the toolbar
  icon and the panel container share id="SitesTool"

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* Revert "chore: bump version to 5.0.1-20260505 [version bump]"

This reverts commit 4880204c1163be5d1d7fa96d14a0ed018c6f586c.

* fix: prevent filter operator dropdown clipping in Layers panel

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260507 [version bump]

* revert: keep dropy openUp:true for operator dropdowns

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit d67c369ed437e47d658ae051348d377978dc48ed.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 29565ed829a55e9c241a789c9a3901d11cb5ca67.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 50e357604ebe9378564619b34c508b63cfb62c1d.

* chore: bump version to 5.0.1-20260507 [version bump]

* chore: bump version to 5.0.2-20260511 [version bump]

* fix: render Globe panel immediately on first open without window resize

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260511 [version bump]

* feat: add theme borders to panels and gradient backgrounds to splitters

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: bump split shadow gradient opacity to 0.4

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal 3-col grid + smaller leaflet zoom button gap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: prevent hotkey label/value wrapping (ellipsis instead)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal single column, no wrap, no truncation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: hotkeys modal dividers, invert title/subtitle colors, rename title, margin above subtitles

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: move splitter gradient to themed CSS class, restore hover feedback

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* style: hotkeys section titles use --color-h (matches rest of app)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* fix: guard Globe_.init() inside rAF to prevent double instantiation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.6-20260512 [version bump]

* feat(plugins): per-plugin deps, lazy tool loading, validation, shared discovery

Phase 3 — Plugin config validation + override warnings:
- New API/pluginValidation.js with validatePluginConfig() for tool, component,
  and backend manifests. Validates required fields (name, paths), object/string
  shape of paths, dependencies block (npm/python.pip/python.conda), and warns
  on unknown top-level fields.
- updateTools()/updateComponents() now skip invalid plugins and emit override
  warnings (matching what components already logged for tools).

Phase 2 — Shared discoverPlugins() utility:
- New API/pluginDiscovery.js consolidates the duplicated scanning logic from
  updateTools(), updateComponents(), and getBackendSetups(). Supports exact-
  name and substring container patterns, JSON/require/no-op loaders, and skips
  dot/underscore-prefixed dirs.
- updateTools.js and setups.js refactored on top of the shared helper.

Phase 1 — Per-plugin dependency declaration + build-time aggregation:
- Plugin config.json may now declare a 'dependencies' block (npm + python.pip +
  python.conda). validatePluginConfig() also validates this shape.
- New scripts/resolve-plugin-deps.js scans every tool/component/backend plugin
  and writes plugin-package.json, plugin-python-requirements.txt, and
  plugin-conda-deps.txt. Detects version conflicts and fails loudly.
- scripts/build.js calls resolvePluginDeps() before updateTools().
- Dockerfile installs the aggregated plugin npm and pip deps after the root
  npm ci, using --no-save / --no-package-lock / --ignore-scripts so the root
  lockfile is untouched.
- Animation tool migrated: ffmpeg/gifshot/html2canvas now declared in its
  config.json (kept in root package.json for transitional compat).
- Generated artifacts gitignored.

Phase 4 — Lazy loading of tool bundles:
- updateTools() now emits dynamic-import arrow functions in the generated
  src/pre/tools.js with webpackChunkName hints so each tool is split into
  its own chunk (Kinds stays static because it's required synchronously).
- ToolController_ gains ensureToolLoaded(name) and getLoadedTool(name) helpers
  and makeTool is async; init/finalizeTools and the separated-tool auto-open
  flow are updated to handle lazy modules.
- Toolbar.jsx, SeparatedTools.jsx, SitesTool.js, and Layers_.js migrated to
  resolve LayersTool/etc. via the new helpers instead of poking toolModules
  directly.

Tests & docs:
- tests/fixtures/test-plugin-tools/{TestPlugin,InvalidPlugin,OverridePlugin}
  + tests/helpers/plugin-helpers.js with install/uninstall helpers.
- New unit specs: pluginValidation, pluginDiscovery, updateTools,
  resolvePluginDeps, toolLazyLoading (57 tests, all passing).
- CONTRIBUTING.md and docs/pages/Contributing/Contributing.md updated with
  schema, override behaviour, dependency declaration, build-time aggregation,
  conflict detection, and Docker integration.

* chore: bump version to 5.0.7-20260512 [version bump]

* fix: make Globe_.init() idempotent against multi-init

Globe_.init() previously constructed a fresh GlobeRenderer on every call,
which after #71 could happen multiple times for a single toggle (uiStore
setTimeout + TopBar rAF). Each extra construction appends another
.cesium-widget / _lithosphere_scene to #globe and leaves event handlers
wired to dereferenced renderer state, which has been observed to break
LithoSphere globe control buttons on configurations where the globe panel
starts closed at boot.

Add a top-of-init() guard that bails out and calls invalidateSize() when
a renderer already exists. Single small, surgical change; no behavior
change for the !L_.hasGlobe mock-swap path or for first-time construction.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.7-20260512 [version bump]

* test(plugins): generate src/pre/tools.js on demand in toolLazyLoading spec

The Playwright unit-tests CI step runs before `npm run build` so the
gitignored `src/pre/tools.js` artifact does not yet exist on disk.
Add a beforeAll hook that invokes `updateTools()` to regenerate it
when missing, keeping the spec self-contained on both CI and dev
machines that already built locally.

* fix(tools): defensive getTool() + preload flag for cross-referenced tools

Devin Review flagged a behavioural regression introduced by Phase 4:
`ToolController_.getTool(name)` previously always returned a method-
callable object (real module or `{ use(){} }` stub) because every tool
was statically imported. After Phase 4, unresolved lazy loaders are
`() => import(...)` functions, so callers like `Map_.getTool('InfoTool').use(...)`,
`mmgisAPI.getTool('DrawTool').filesOn`, and `LegendTool` calling
`LayersTool.populateCogScale` would crash with TypeError until the
target tool was opened.

Two fixes:

1. **Defensive getTool()**: Returns the legacy fallback stub when the
   tool module is still a lazy-loader function, and fires off
   `ensureToolLoaded(name)` in the background so subsequent calls see
   the resolved module. Prevents all crashes immediately.

2. **`preload: true` config flag**: Tools reached synchronously from
   other code paths (Info, Draw, Layers, Chemistry) now declare
   `"preload": true` in their `config.json`. `ToolController_.init()`
   calls `preloadEagerTools()` which fires `ensureToolLoaded` for
   every such tool right after toolbar setup — the chunks download
   in parallel with the rest of the page becoming interactive, so by
   the time a user clicks a feature the InfoTool module is already
   resolved.

`validatePluginConfig` now accepts `preload` as a known tool field;
CONTRIBUTING.md and docs/pages/Contributing/Contributing.md updated to
document when to set it. Added a unit test c…
* Sync toggleTimeUI DOM state to Zustand store

toggleTimeUI() now calls setTimeUIActive() and setTimeUIExpanded()
so BottomFloatingBar visibility and BottomElementPositioner offsets
reflect actual TimeUI state.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore #toggleTimeUI element in Coordinates markup

The element was accidentally dropped during the folder restructure move.
TimeUI.js and DrawTool.js check $('#toggleTimeUI').hasClass('active')
to gate histogram rendering and time-filter toggle visibility.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix #toggleTimeUI: restore click handler, tippy, active class; remove redundant jQuery positioning

Restored pieces lost during folder restructure:
- Click handler in init() and off handler in remove()
- Tippy tooltip for the time toggle button
- display:none when time is not enabled
- $('#toggleTimeUI').toggleClass('active') so TimeUI.js can check it
- $('#CoordinatesDiv > #toggleTimeUI').remove() on mobile

Removed jQuery CSS positioning from toggleTimeUI() since
BottomElementPositioner now reactively handles all bottom-anchored
element offsets via the Zustand store.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix CurtainTool.destroy() using undefined ReactDOM.unmountComponentAtNode

Use the stored _reactRoot.unmount() instead, matching the React 18
createRoot pattern already used in make().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix tooltips, scale indicator position, modal blur, help close button

Tooltips:
- Reduce Base UI Tooltip delay from 600ms (default) to 200ms
- Restyle tooltip popup to match tippy blue theme (var(--color-c2))
- Add Tooltip wrappers to TopBar panel toggles (Viewer/Map/Globe)
- Wrap Toggle with forwardRef so Tooltip render prop can attach ref
- Remove title attrs that conflicted with custom tooltips

Scale indicator:
- Remove scalefactor-specific positioning from BottomElementPositioner
  (it moves naturally with .leaflet-bottom.leaflet-left container)
- Position scalefactor to the left of compass at same bottom level

Modal blur:
- Call _applyBlur() immediately when marking modal as closing
  so blur clears during fade-out instead of persisting 500ms

Help modal:
- Add close (X) button in title bar matching other modal patterns

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Remove dead CSS: delete tools.css, clean ~600 lines from mmgisUI.css and mmgis.css

- Delete tools.css entirely (both selectors #CurtainToolList and
  .searchToolSelect are unreferenced anywhere in the codebase)
- Remove from mmgisUI.css: .mmgisRadioBar3/4/Vertical (140 lines),
  .mmgispureselect (104 lines), blink/condemned_blink_effect (38 lines),
  .slidecontainer/.slider (41 lines), .ar_slider (91 lines),
  .verticalSlider (91 lines), .mmgisMultirange_elev (19 lines),
  .ui-corner-all/bottom/right/br (9 lines)
- Remove from mmgis.css: #nodeenv, empty #topBar{}, #topBarInfo,
  #topBarHelp, #topBarFullscreen, #toggleUI, #logoGoBack
- Keep #topBarLink (used in BottomBarReact.jsx), #webgl-error-message
  (used by vendored THREE.js)
- All selectors verified with repo-wide grep before removal

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI fixes: tooltips, splitter hover, mobile toolbar, color schemes

- Replace Base UI Tooltip with simple React portal tooltip (200ms delay,
  tippy-matching style) — fixes missing tooltips for toolbar/topbar/bottom buttons
- Add cursor + hover highlight to vertical splitters (was missing because
  module CSS didn't inherit global .splitterV styles)
- Add hover highlight to tool panel drag handle
- Remove mdi-drag-vertical icon from tool panel drag
- Add mobile toolbar horizontal layout via @media query overrides
- Add 4 new color schemes: High Contrast (a11y), Dark Mars, Dark Midnight,
  Light Warm (total: 10 themes)
- Previous fixes also included in working tree:
  - timeUI border moved to toolsWrapper border-bottom (conditional)
  - #toggleTimeUI button removed entirely
  - CoordinatesDiv: vertical centering, unified background, 12px right offset
  - barBottom padding-bottom: 8px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix assignment operator used instead of comparison in TimeControl.fina()

Pre-existing bug: `TimeControl.enabled = true` was assigning instead of
comparing. Changed to `TimeControl.enabled === true`.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restructure Configure page UI tab: add all themes, Custom mode, enableWhenField

- Added all 10 theme presets to dropdown (was missing Dark Mars, Dark Midnight,
  Light Warm, High Contrast)
- Added 'Custom' option: skips preset theme, uses only color picker values
- Moved Theming section directly under Rebranding
- Nested 'Custom Color Options' under Theming with subdescription
- Added enableWhenField support to Maker.js: disables color pickers unless
  theme is set to Custom
- Renamed color options with clearer names and improved descriptions:
  Primary → Surface Color, Secondary → Deep Background Color,
  Tertiary → Text Color, Body → Page Body Color, Highlight → Feature Highlight
- Stylize.js: skip setTheme() when theme is 'Custom'
- Rebuilt configure page

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert tooltips to tippy.js, fix dropdown menus, redesign About modal

1. Tooltip: Replaced custom React portal tooltip with tippy.js wrapper.
   Uses the existing tippy.js dependency and 'blue' theme for consistency.

2. Dropdown: Replaced Base UI Menu with native portal dropdown.
   Base UI's nested Menu.Trigger + BaseButton composition was swallowing
   click events, breaking userAvatar and menuBtn menus. New implementation
   uses simple state + createPortal with proper outside-click dismissal.

3. About modal: Professional redesign with centered MMGIS ASCII art header,
   proper GitHub SVG logo link, clean metadata section, centered link
   buttons, attributions section, and NASA-AMMOS footer.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore .mmgisHelpButton base styles lost during Help.css module migration

The global .mmgisHelpButton styles (yellow color, compact 18x18px sizing,
0.7 opacity) were removed when Help.css was converted to Help.module.css.
Since Help.getComponent() emits raw HTML strings for jQuery-rendered tool
headers, it cannot use CSS Module scoped classes. Restored the base styles
in mmgis.css alongside the related .mmgisToolHelpBtn definition.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix session logout regression, About modal refinements, High Contrast theme, Stylize.js, Default Tool config

- Login: skip session.regenerate() for token-based re-auth (useToken:true)
  so reloading the main page no longer invalidates the configure page session
- About modal: replace ASCII art with mmgis.png logo, rename Attributions to
  Map Layer Attributions, remove footer logo, link NASA-AMMOS to ammos.nasa.gov
- High Contrast theme: change accent from #ffff00 to #ffd700 (gold) for better
  contrast ratios against dark backgrounds
- Stylize.js: color overrides only apply when theme is Custom or unset,
  preventing preset themes from being clobbered by stale config values
- Restore Default Tool config section in tab-ui-config.json (accidentally
  removed during Theming section reorganization)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore defaulttooldropdown case handler in Maker.js

The case was accidentally removed during the Configure page UI tab
restructure (d7f96c50). Without it, the Default Tool dropdown in the
Configure page rendered as nothing despite the config referencing it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* High Contrast tooltip text, panel toggle styling, scale position swap

- High Contrast theme: tooltips now use black text on yellow background
  via --color-c2-text variable (white for all other themes)
- About modal links use var(--color-f) for consistent theme text color
- Panel toggle buttons: 11px uppercase with 600 weight for better
  visibility
- Mapping scale button moved to bottom-right of compass (was top-left)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Reposition Viewer and Globe panel buttons to top-right

- Viewer: dropdown selector at top-right edge, OSD buttons stacked
  vertically below it; settings panel opens to the left
- Globe: home, exaggerate, observe, walk, link controls moved from
  TopLeft to TopRight corner via addControl 4th arg
- Style consistency: OSD buttons and LithoSphere controls now match
  Leaflet zoom controls (var(--color-a) bg, var(--color-f) text,
  var(--color-mmgis) hover, 30px size, 3px border-radius)
- Viewer settings sliders use var(--color-a3) instead of hardcoded
  #444444
- Az/el indicator stays at bottom center (exception per design)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Consistent modal theming + session security fix

Modal theming:
- All modals now share consistent styling: backdrop-filter blur, semi-transparent
  background via --color-a-rgb, 10px border-radius, header divider line, box-shadow
- Updated: loginModal, Help, ConfirmationModal, Settings, Hotkeys, About modals
- Tool panel backgrounds changed from opaque var(--color-k) to transparent so the
  ToolPanel's existing backdrop-filter effect shows through
- Legend tool header updated to match consistent 44px height with divider
- applyTheme.js now auto-derives --color-a-rgb from theme's --color-a hex value
- Modal service wrapper gets backdrop-filter: blur(12px)

Session security (Devin Review fix):
- Token re-auth now calls req.session.regenerate() with data preservation to
  prevent session fixation while maintaining multi-tab compatibility
- Token is rotated via crypto.randomBytes on every re-auth (was being reused)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI fixes: viewer settings, button sizing, menu contrast, coords, login header

1. Viewer OSD settings moved to top of button stack, panel opens downward
2. Overlay buttons consistent 30x30px (OSD line-height fix, home button)
3. Menu/icon contrast improved: Dropdown items and IconButtons use --color-a5
   (was --color-a3) with --color-f on hover for better dark theme legibility
4. CoordinatesDiv fixed to 30px height, pickLngLat button centered
5. Login modal now has a header bar with 'Log In' title and close X button;
   title toggles to 'Sign Up' when switching modes

Also reverts session regeneration for token re-auth (Devin Review feedback):
token-based re-auth now refreshes session data in-place without regeneration
or token rotation, preserving multi-tab compatibility.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI polish: nav popover, sep tools, compass, zoom controls, status indicator, toast

1. Description nav popover: added z-index:9000 so menu appears above map panels
2. Separated tools: default color changed from accent to --color-f; fixed CSS
   selector from .toolButtonSep to .toolSep to match actual class names
3. Compass + mapping scale shifted left by 30px for better positioning
4. Map zoom/home controls: use --color-f instead of accent --color-c to reduce
   visual prominence; hover still highlights with accent color
5. Status indicators (reload/ws disconnect/layer update) moved from Leaflet
   control to TopBar with soft pulsing fade animation and tooltip on hover
6. WebSocket retry toast: rounded corners, glass background with backdrop-filter,
   border-left accent for failure state instead of solid red background

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix 14 tool UI issues: headers, backgrounds, layout, functional bugs

Header alignment:
- LayersTool: align-items center on #filterLayers, gap between right icons
- InfoTool: align-items center on #infoToolHeader, 44px height
- ViewshedTool: align-items center, restructured header with left/right divs
- IsochroneTool: restructured flat header into nested mmgisToolHeader pattern
- ShadeTool: align-items center on #vstHeader children

Icon spacing:
- LayersTool: increased right margin to 28px + gap 2px
- ViewshedTool: #vstNew padding-right 30px (clear of close button)
- IsochroneTool: #iscNew padding-right 30px

Missing components:
- SitesTool: added Help import + help icon via mmgisToolHeader pattern
- AnimationTool: added full mmgisToolHeader with title and help icon

Background fixes:
- InfoTool: changed toolsContainer background from transparent to var(--color-a)
- DrawTool: changed toolsContainer background from transparent to var(--color-a)

Layout fixes:
- DrawTool: #drawToolContents top 81px, height calc(100%-81px), #drawToolNav margin-right 0
- MeasureTool: removed padding-left:0 override from mmgisToolHeader child selector

Functional fixes:
- InfoTool: updated jQuery selectors from #InfoTool to #toolButtonInfo (React toolbar IDs changed)
- CurtainTool: deferred OpenSeadragon init with requestAnimationFrame (React 18 async render)
- CurtainTool: curtainToolBar justify-content flex-end (icons at bottom)

Security:
- TopBar StatusIndicator: escape HTML in layer names to prevent XSS via addLayerQueue

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix mapToolBar pointer events, login padding, default tool, About modal order

1. mapToolBar: set pointer-events:none on both #mapToolBar and direct children
   so clicks pass through to the map; leaf elements still get auto via
   .childpointerevents rule
2. #loginModalBody: padding changed to 40px 0px 0px
3. Default tool: deferred click to requestAnimationFrame so React toolbar
   has rendered before getElementById runs
4. About modal: moved mainInfoModalCustom to right below mainInfoModalHero

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix tool headers to 40px, ViewshedTool subheader, AnimationTool header, InfoTool close btn, statusIndicator position

1. All tool panel headers: changed from 44px to exactly 40px height
   - Global .mmgisToolHeader and .mmgisToolTitle in mmgis.css
   - InfoTool.css, ViewshedTool.css, IsochroneTool.css, ShadeTool.css

2. LayersTool #filterLayers: height 40px, .right > div height unset,
   .right margin-right 30px, .right > div margin 0px 3px

3. ViewshedTool: restructured header — title+help in mmgisToolHeader row,
   vstToggleAll (left) and vstNew (right) on a new #vstSubHeader row below

4. AnimationTool: removed old #animationToolHeader CSS (padding 15px 20px,
   white background, 18px font), now uses standard mmgisToolHeader class.
   Fixed color from var(--color-a) (background) to var(--color-f) (text)

5. InfoTool close X: re-inject close button after use() rebuilds content
   via TC_.injectCloseButton() (toolsContainer.empty() was removing it)

6. StatusIndicator: moved to left of topBarTitle in JSX render order.
   Added align-items:center to #topBarMain for vertical alignment.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Remove title attr from StatusIndicator (conflicts with tippy tooltip)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TimeUI dropdown z-index above tool panel, reposition toasts to top-center

1. TimeUI dropdowns: added z-index 10000 to all dropy content ul elements
   so they render above the vertical tool panel (z-index 1400). Also set
   timeUIDock to position:relative with z-index 10000 and overflow:visible.

2. Toast notifications: repositioned #toast-container from bottom-right to
   top-center just below the topbar (top: 44px, centered with transform).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix StatusIndicator spacing, CurtainTool close btn, header title font consistency

1. StatusIndicator: use display:none/flex instead of opacity:0/1 so it
   takes no space when there's no active status indicator.

2. CurtainTool: added close X button at top of curtainToolBar (matching
   MeasureTool pattern) with flex spacer pushing other buttons to bottom.

3. Header title font consistency: InfoTool, ShadeTool, CurtainTool titles
   now match mmgisToolTitle standard (font-weight:600, padding-left:10px,
   height:40px for CurtainTool which was 34px).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Legend empty state, scalefactor position, sep-tool-header unbold

1. Legend: show 'No active layers with legends' when no legend items
   are present. Also fixed container height calc(100% - 40px).

2. Mapping Scale (.leaflet-control-scalefactor): shifted 10px right
   (left 26→36px) and 1px down (bottom 30→29px).

3. sep-tool-header span: font-weight changed from 600 to 400.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Guard Legend empty state message to only show when panel is active

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TimeUI dropdown covered by toolPanel: remove splitscreens stacking context

#splitscreens had z-index:1 which created a stacking context, confining
its children (including bottomFloatingBar at z-index:1500) to that context.
Since ToolPanel (z-index:1400) was a sibling outside splitscreens, it
painted above all splitscreens children regardless of their internal
z-index values.

Fix: change #splitscreens z-index from 1 to auto so it no longer creates
a stacking context. Now bottomFloatingBar (1500) participates in the
same stacking context as ToolPanel (1400), and 1500 > 1400 means the
TimeUI dropdown correctly paints above the tool panel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Migrate ~69 CursorInfo toast-like calls to proper Toast component

- Replace CursorInfo.update() toast-like calls with Toast.info/success/warning/error
- Preserve message colors: blue→info, green→success, yellow→warning, red→error
- Keep 12 legitimate cursor-following CursorInfo calls unchanged
- Files migrated: DrawTool.js, DrawTool_Files.js, DrawTool_FileModal.js,
  DrawTool_Templater.js, DrawTool_SetOperations.js, DrawTool_Drawing.js,
  DrawTool_Editing.js, DrawTool_Shapes.js, LayersTool.js, ShadeTool.js,
  chemistrychart.js
- Fix Devin Review: Change misleading 'Bad token' to 'Login failed' in users.js
- Normalize line endings (CRLF→LF) in affected files

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Toast.js missing from git, CoordinatesDiv z-index, Legend duplicate ID, topBar padding

- Add Toast.js to version control (was untracked, causing webpack module error)
- Bump CoordinatesDiv z-index from 20 to 1001 (was hidden behind splitscreens
  children after z-index:auto change)
- Fix Legend duplicate ID: separated tool icon was #LegendTool, same as content
  container div, causing empty message to appear in button instead of panel
- Add hasStatus class to #topBarMain when statusIndicator is active, setting
  #topBarTitleName padding-left to 0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Legend empty message: scope selector to content container via targetId

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Move Toast.js to design-system, fix --color-a3 text contrast, update AGENTS.md

- Move Toast.js from UserInterface_/components/Toast/ to design-system/components/Toast/
  (generic component belongs in design-system, not MMGIS-specific UserInterface_)
- Update all 11 Toast import paths to new location
- Bump --color-a3 in 5 dark themes to pass WCAG AA 4.5:1 contrast for text:
  Dark Default #747c81→#81888d, Dark Blue #64748b→#738399,
  Dark Warm #8b7a5e→#908064, Dark Mars #8a6a60→#98796f,
  Dark Midnight #606088→#7a7a9e
- Update AGENTS.md: document design-system/ vs UserInterface_/ distinction
  in project structure and Key Directories

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix topBarTitleName padding override: increase specificity and add !important

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix IdentifierTool deactivation: update icon ID reference in separateFromMMWebGIS

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Hide toolPanelDrag when no tool is open

- Add setToolPanelDragVisible(false) to closeActiveTool() (was only in makeTool toggle-off path)
- Also guard drag handle display on isOpen (toolPanelWidth > 0) as safety net

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add hover effect to MMGIS logo (opacity + brightness transition)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix MMGIS logo hover: keep full opacity, use subtle background highlight instead

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Selective tile fade: fade on pan/zoom, instant on refresh/reload

- Remove blanket _fadeAnimated toggle from toggleTimeUI (was killing fade
  for all tiles while TimeUI was open)
- Monkey-patch GridLayer.redraw, TileLayer.setUrl, and GridLayer._tileReady
  to suppress fade via a transient _suppressTileFade map flag
- Set _suppressTileFade in reloadTimeLayers for time-driven reloads
- Flag auto-clears after 300ms so pan/zoom tiles still get the nice fade
- Install pbf dependency (required by CesiumMVTLayer from #942 merge)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Per-layer fade control: time-enabled + shade/viewshed layers never fade

- Replace transient map-level _suppressTileFade with per-layer _noFade flag
- Patch GridLayer._tileReady to check _noFade on the layer instance
- Set _noFade on time-enabled tile layers and data/GL layers at creation
- Set _noFade on Shade and Viewshed tool GL layers
- Non-time-enabled base imagery tiles still fade normally on pan/zoom

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile UI improvements — move hamburger to right menu, show panel toggles, isMobile-driven toolbar layout, desktop-matching scalebar/compass

- Remove left hamburger menu (#topBarMenu), move BottomBar items into
  top-right kebab dropdown menu for both mobile and desktop
- Show panel toggles (Viewer/Map/Globe) and account/login UI in mobile
  topbar's #topBarRight
- Move toolbar horizontal layout CSS from @media breakpoints to
  UserInterfaceMobile_.css (loaded only when isMobile flag is true)
- Remove #mapTopBar @media rule from mmgis.css, add to mobile CSS
- Remove mobile-only simplified scalebar rendering; use full desktop
  scalebar with both large and small axes on all viewports
- Remove display:none on .leaflet-control-scalefactor in mobile CSS
- Remove #loginDiv display:none from mobile CSS (React overlay handles it)
- Simplify BottomBarReact container styles (no more absolute positioning)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260430 [version bump]

* fix: mobile toolbar 40px height, bottomFloatingBar flush, timeUI toggle, scalebar position, hide hotkeys

- Toolbar height 40px, toolButton width 40px, no border-bottom
- toolcontroller_incdiv: no padding-bottom, overflow-y hidden
- bottomFloatingBar: no border-radius, left/right/bottom = 0
- Add MobileTimeUIToggle button on far right of toolbar
- Hide Keyboard Shortcuts from kebab menu on mobile
- Fix scalebar positioning (remove top:48px override in UserInterfaceBridge)
- Set mobileTopSize/topSize to 40 (splitscreens top = 40px, not 50px)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile topBar padding-left 34px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: update MobileCoordButton topBar paddingLeft from 80px to 34px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: MobileTimeUIToggle — inline toggle logic, float right, hide from settings on mobile

- Replace broken Coordinates.toggleTimeUI() call with direct jQuery/store toggle
- Float time button right in toolbar
- Hide Time UI toggle from settings modal on mobile (toolbar has it)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: push scalebar/compass/scale up 40px on mobile, keep #timeUI in DOM

- BottomElementPositioner: position mapToolBar, leaflet-bottom-left/right
  40px above bottom on mobile (above toolbar)
- Stop removing #timeUI from DOM on mobile so MobileTimeUIToggle works

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — only show endtime, always expanded

- Hide #mmgisTimeUIStartWrapper and StartWrapperFake on mobile via CSS
- Force expanded state (addClass expanded + show) when toggling TimeUI on
- CSS ensures #timeUI.active always shows expanded content on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI opens in tool panel with header, end time, expanded rows

- MobileTimeUIToggle now opens/closes the tool panel via ToolController_
- Closes any active tool before showing TimeUI
- Forces expanded state when opening
- CSS hides start time inputs, positions expanded content properly
- Overrides absolute positioning of expanded content for tool panel flow

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: rewrite separated tools system from jQuery to React components

- Add separatedToolsList/activeSeparatedTools state to Zustand uiStore
- Rewrite SeparatedTools.jsx with glassmorphism panels, CSS Module styling
- Replace SepToolsContainer (setInterval hack) with SepToolButton/SepToolsSection
- Remove ~170 lines of jQuery DOM construction from ToolController_.js
- Fix hardcoded rgba(26,26,27,0.88) to theme-aware var(--color-a-rgb)
- Remove separated tool entries from themeApplier.js
- Remove separated tool overrides from FloatingElements.css
- Move Legend CSS overrides from Toolbar.module.css to SeparatedTools.module.css
- Remove jQuery active-state manipulation from IdentifierTool.js
- Add store sync in Map_.js displayOnStart logic
- Preserve all DOM IDs for backward compatibility (mmgisAPI, tool make())

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260501 [version bump]

* fix: TimeUI mobile checks — use Zustand store instead of L_.UserInterface_

L_.UserInterface_ is null when TimeUI.init() runs (TimeControl.init is called
before L_.link sets UserInterface_). All 16 isMobile checks now read from
useUIStore.getState().isMobile which is set at startup.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: move displayOnStart logic from Map_.js to ToolController_.finalizeTools()

- Map_ no longer references specific tools (LegendTool)
- displayOnStart is now handled generically for all separated tools
- Added DOM element polling (tryMake) to handle React render timing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* revert: remove all TimeUI-related mobile changes

Reverts TimeUI.js and BottomBar.js to development base.
Restores #timeUI DOM removal in UserInterfaceBridge.fina().
Removes MobileTimeUIToggle component from Toolbar.jsx.
Removes TimeUI mobile CSS overrides from UserInterfaceMobile_.css.
Non-TimeUI refinements (toolbar height, scalebar positioning, etc.) preserved.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* simplify: remove DOM polling, use simple setTimeout(0) for auto-open

LegendTool handles its own content lifecycle via subscribeOnLayerToggle.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: mobile TimeUI — fix isMobile detection, staging container, toolbar toggle

- TimeUI.js: import useUIStore and replace all 16 L_.UserInterface_?.isMobile
  checks with useUIStore.getState().isMobile (L_.UserInterface_ is null when
  TimeUI.init() runs, so mobile conditionals were dead code)
- TimeUI.js: stage mobile #timeUI in hidden #timeUIMobileStaging instead of
  placing directly in #tools (which gets cleared by other tools)
- UserInterfaceBridge.js: stop removing #timeUI from DOM on mobile
- Toolbar.jsx: add MobileTimeUIToggle that moves #timeUI between staging and
  #tools, opens/closes tool panel via ToolController_
- BottomBar.js: hide TimeUI toggle from settings modal on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: rescue #timeUI back to staging when another tool opens

Subscribe to activeToolName changes — when a tool becomes active while
TimeUI is showing, move #timeUI back to #timeUIMobileStaging before
the new tool's make() clears #tools.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove separatedTool/justification config toggles, fix review issues

- Remove separatedTool checkbox and justification dropdown from Legend
  and Identifier config.json (these are always separated, not configurable)
- Remove justification property/code from LegendTool.js, IdentifierTool.js
- Simplify Globe_.js separated tool count (no justification filter)
- Remove justification from Reference-Mission config blueprint
- Update LegendTool help docs and Legend.md documentation
- Add --color-a-rgb fallback (29,31,32) in SeparatedTools.module.css
- Add display:none !important to .panelIdentifier to prevent 12px gap
- Update e2e test comment

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: circular import in TimeUI.js, toolbar/bottomFloatingBar position sync

- TimeUI.js: replace top-level useUIStore import with lazy _getUIStore()
  accessor to avoid 'Cannot access useUIStore before initialization'
  circular import error at _remakeTimeSlider
- SplitScreens.jsx: skip #timeUI reparenting observer on mobile (mobile
  uses MobileTimeUIToggle to manage #timeUI placement in #tools)
- BottomElementPositioner.jsx: unify mobile transition to 0.3s (matches
  toolsWrapper and toolbar), guard pxIsTools against undefined
- Toolbar.jsx: align toolbar transition to 0.3s ease-out

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* LegendTool fix empty message

* chore: remove separated tools offset logic from Globe_.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: skip _makeHistogram on mobile (no timeline slider, timestamps unset)

_makeHistogram renders inside the timeline slider which doesn't exist
on mobile. Without it, _timelineStartTimestamp is NaN, causing
'Invalid time value' RangeError at toISOString().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — populate expanded rows, fix Invalid date, fix panel height

- TimeUI.js attachEvents: use _initialStart/_initialEnd on mobile (same
  as desktop) instead of L_.TimeControl_ which isn't set yet at init time.
  Fixes 'Invalid date' in start/end time inputs.
- TimeUI.js fina: set expanded=true on mobile and call _populateExpandedRows()
  so year/month/day/hour rows actually render. Removed position:absolute and
  pointer-events:none overrides.
- Toolbar.jsx: set tool panel height to 217px (TimeUI.height) instead of
  45% viewport — matches actual TimeUI content height.
- UserInterfaceMobile_.css: expanded content flows naturally (position:relative),
  hide start time inputs, allow overflow scroll, flex-wrap topbar.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI justify-content center, restore toolbar border-bottom

- Add justify-content: center to #mmgisTimeUIMain on mobile
- Remove border-bottom: none override so toolbar keeps its default border

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI overflow hidden, scalebar/compass fixed at 40px offset

- #timeUI overflow-y: hidden (was auto, causing 2px scroll)
- Scalebar/compass/map controls stay at fixed 40px offset (above toolbar)
  regardless of tool panel state — no longer shift up by pxIsTools

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Implement multi-tier knowledge architecture

- Restructure AGENTS.md from 745 lines to 106 lines (Tier 1: essential context)
- Create knowledge/ directory with 30+ wiki-style documentation files (Tier 2: deep knowledge)
- Create knowledge/reference/ with 8 detailed reference files (Tier 3: lookup material)
- Move AI-GETTING-STARTED.md and AI-DEVELOPMENT.md to knowledge/
- Update all file references in .specify/templates and blueprints
- Create knowledge/README.md as the full knowledge base index
- Create knowledge/reference/README.md as reference material index

Three-tier knowledge discovery system:
  Tier 1: AGENTS.md (~106 lines) - scannable in <2 minutes
  Tier 2: knowledge/*.md - deep knowledge on architecture, tools, APIs, DB, infra
  Tier 3: knowledge/reference/*.md - coding conventions, API reference, troubleshooting

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: mobile toolbar active button style matches desktop, fix icon alignment

- All mobile toolbar buttons (ToolButton, MobileCoordButton, MobileTimeUIToggle)
  now use display:flex with align-items/justify-content center for proper
  vertical icon centering
- MobileCoordButton: changed 'active' class to 'toolButtonActive' to match
  the global CSS active style (color-mmgis + color-i background)
- Removed inline color overrides so CSS .toolButtonActive takes effect

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add Devin knowledge notes from past MMGIS sessions

Include curated lessons learned from past Devin sessions:
- CI/CD: ignore build-arm64/amd64 failures, focus on required checks
- Child sessions: no separate PRs when consolidating
- ENV triple-update rule (.env, sample.env, ENVs.md)
- Error handling: use logger with infrastructure_error for fatal startup errors
- Path traversal security: stay within /Missions, handle subpath serving
- Database initialization architecture and migration patterns
- API authentication behavior across AUTH modes
- Auto-generated MMGIS concept index

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar active button style, icon alignment, tool deactivation

- Active toolbar buttons get desktop-matching margin (1px 0) and
  border-radius (8px) via .toolButton.toolButtonActive CSS rule
- Removed line-height: 40px from .toolButton (flex centering handles
  vertical alignment, line-height was pushing icons up)
- MobileCoordButton now watches activeToolName store and deactivates
  when another tool opens (fixes coords staying active)
- MobileTimeUIToggle sets activeToolName='MobileTimeUI' when opening
  so coords/other buttons can detect it and deactivate
- MobileTimeUIToggle clears activeToolName when closing
- Both custom buttons skip self-deactivation via name check

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix cross-references: convert backtick refs to markdown links, add Devin knowledge notes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon height 40px, button margins for active padding

- #toolbar .toolButton i: height 40px fixes icon vertical alignment
- #toolbar .toolButton: margin 0 2px gives spacing between buttons
- #toolbar .toolButton.toolButtonActive: margin 1px 2px so active
  background has visual padding around the icon

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename knowledge/ to .knowledge/ for consistency with .specify/ convention

Dot-prefix signals agent infrastructure (not source code), consistent with
.specify/, .github/, .vscode/ conventions. All cross-references updated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon line-height 40px, active button padding via height

- Coord and TimeUI button <i> icons get line-height: 40px
- Active buttons: height 34px (vs 40px toolbar) creates visual padding
  around the active background, centered by flex align-items
- Buttons get margin: 0 1px for horizontal spacing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix broken cross-reference: 06.2 -> 06.1-configure-rest-api.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: close active tool + cancel deferred cleanup in MobileCoordButton/TimeUI

- MobileCoordButton: call closeActiveTool() before opening, destroy
  _pendingCloseTool if set, increment _closeSeq to cancel deferred
  tools.innerHTML clear
- MobileTimeUIToggle: same _pendingCloseTool + _closeSeq fix after
  closeActiveTool() to prevent 420ms deferred cleanup from wiping
  #timeUI after it's placed in #tools
- Removed redundant closeActiveTool() from MobileCoordButton close path
  (was being called after destroy, not needed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: active mobile toolbar buttons 34x34px (square)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Drastically compress .knowledge/ — keep only unique agent content

Remove 33 wiki files that duplicate docs/pages/ content.
Remove 9 reference/ files derivable from source code.
Keep only 5 files (down from 46):
  - AI-GETTING-STARTED.md (agent setup walkthrough)
  - AI-DEVELOPMENT.md (spec-kit workflow)
  - conventions-and-gotchas.md (naming, code style, common issues)
  - 12-devin-knowledge-notes.md (CI, auth, DB init, security gotchas)
  - README.md (index pointing to docs/pages/ for everything else)

Principle: don't duplicate docs/ — only keep what's uniquely agent-optimized.
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename to knowledge-notes.md, remove Devin branding and fork-specific CI section

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: hide mmgis-map-logo on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore Database Safety Rules for AI Agents section in AGENTS.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: shift compass and map scale 6px to the right (both mobile and desktop)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add back Important Instructions, code pattern templates, and detailed project structure

- Important Instructions in AGENTS.md: MCP tools, hot-reload, Reference Mission
- .knowledge/code-patterns.md: full directory tree with key directory annotations,
  plus copy-paste templates for Express routes, Sequelize models, Tool plugins,
  and WebSocket handlers

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Update project structure trees to reflect current filesystem

Add missing directories: tests/, .knowledge/, .specify/, .github/, views/,
private/, spice/, build/, examples/, scripts/middleware.js.
Both abbreviated (AGENTS.md) and detailed (.knowledge/code-patterns.md) trees
now match the actual repo layout.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.30-20260501 [version bump]

* Add Layers_.js to project structure (key singleton L_)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix project structure: correct API layout, frontend modules, code templates

API/Backend/ uses feature-domain modules (Draw/, Users/, Config/, etc.)
with setup.js + routes/ + models/ per feature — not APIs/ or Databases/.
Frontend essence/ has Components/, Helpers/, LandingPage/, mmgisAPI/,
services/ — not Ancillary/. Basics/ includes all singletons (Globe_,
Formulae_, ToolController_, Viewer_, ComponentController_, Test_).
Code templates updated to match actual patterns (setup.js, module.exports).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove test infrastructure (Test_ module, testModules, DrawTool.test)

- Delete src/essence/Basics/Test_/ directory
- Delete src/essence/Tools/Draw/DrawTool.test.js
- Remove Test_ import and Shift+T keydown handler from essence.js
- Remove tests key from Draw tool config.json
- Remove testModules generation logic from API/updateTools.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.31-20260501 [version bump]

* style: move Cesium link button to top-right and match Leaflet zoom button styling

- Change control container from top-left to top-right positioning
- Update button size from 26px to 30px to match Leaflet zoom controls
- Use CSS variables (--color-a, --color-f, --color-mmgis) instead of hardcoded colors
- Add border-radius and box-shadow matching Leaflet control appearance
- Update hover/inactive states to use themed colors

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: anchor map logo to viewport instead of Leaflet map panel

- Change MapLogo parent from .leaflet-bottom.leaflet-right to #main-container
- Switch CSS position from absolute to fixed for viewport anchoring
- Add explicit bottom-offset positioning in BottomElementPositioner (desktop)
- Add explicit bottom-offset positioning in BottomElementPositioner (mobile)
- Logo stays at viewport right edge regardless of open side panels
- Retains smooth bottom offset transitions when bottom bar appears

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: remove references to deleted test infrastructure (Test_, DrawTool.test)

- Remove Test_/ from project structure in .knowledge/code-patterns.md
- Remove DrawTool.test.js references from specs/006 spec, plan, and tasks
- Remove Draw Tool Testing section from tasks.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.32-20260501 [version bump]

* fix: append logo to document.body to avoid filter containing block

#main-container has a CSS filter property which creates a new containing
block per the CSS spec, causing position:fixed to behave like absolute.
Appending to document.body ensures true viewport-fixed positioning.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: prevent mobile topBarTitleName text wrapping by replacing max-width with white-space: nowrap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.33-20260501 [version bump]

* chore: bump version to 5.0.0 and update changelog

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(ui): move Screenshot/Fullscreen to BottomBar, About to TopBar kebab

TopBar kebab menu now contains only Keyboard Shortcuts, Settings, and About
(About now shows on both desktop and mobile).

BottomBarReact now renders Screenshot, Fullscreen, and Copy Link buttons
(top to bottom) following the same IconButton + Tooltip pattern. The
About button has been removed from BottomBarReact.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(mobile): enforce exclusive panel toggling on mobile in TopBar

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* style: reposition LithoSphere globe controls to match Leaflet/Cesium theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(topbar): hide Viewer/Globe toggles based on configured panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): reorder buttons (Copy Link, Screenshot, Fullscreen) and unify size

Reorder the BottomBarReact buttons top-to-bottom to: Copy Link, Screenshot,
Fullscreen.

Move the 24x24 button sizing from the #topBarLink id selector in mmgis.css
into the .barButton class in BottomBarReact.module.css so all three buttons
share the same compact size as the original Copy Link button. Drop the now
redundant #topBarLink rule.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): increase padding-bottom to 12px and button margin to 3px 0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: rearrange globe controls — compass top-right circular, nav row, vertical column, panels open left

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* chore: bump version to 5.0.2-20260505 [version bump]

* style: anchor observe settings panel right:34px and float nav hover panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(theming): add 5 new themes, --color-shadow variable, and configure ThemePreview

- Add Dark Terra, Dark Nebula, Dark Lunar, Dark Supernova, Light Botanical themes
- Add --color-shadow CSS variable to every theme + :root fallback
- Replace hardcoded rgba shadow colors with var(--color-shadow) in TopBar,
  Toolbar, SeparatedTools, ToolPanel, FloatingElements, Dropdown, Modal,
  and SplitScreens
- Add Custom shadowcolor color picker in tab-ui-config and apply it via Stylize
- Add ThemePreview component (configure/src) wired through Maker.js as
  a new 'themepreview' row type so the configure UI shows a live mini
  mockup of the selected theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* fix(configure/ThemePreview): tighten top spacing and live-preview Custom theme

- Pull the preview up by 12px so the gap below the theme dropdown is tighter.
- Read the Custom color pickers (look.primarycolor / secondarycolor /
  tertiarycolor / accentcolor / shadowcolor / topbarcolor / toolbarcolor /
  mapcolor) from the configuration and overlay them on Dark Default so
  the preview reflects Custom theme edits live, matching Stylize.js's
  runtime behavior.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260505 [version bump]

* feat(themes): add Dark Heliosphere, Dark Monokai, and Light Solarized

- Dark Heliosphere: deep night purple surface with corona-orange accent.
- Dark Monokai: warm graphite surface with lime accent (Monokai-inspired).
- Light Solarized: classic solarized base3/base02 with blue accent.

Mirror added to configure/src/themes/themes.js for the ThemePreview, and
the three names appended to the Color Theme dropdown options.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(coordinates): respect time.initiallyOpen when live deep-link is set

* chore: bump version to 5.0.3-20260505 [version bump]

* refactor(theming): remove Custom theme + per-field color overrides

- Drop the 'Custom' option from the Color Theme dropdown.
- Remove all Custom Color Options (look.primarycolor, .secondarycolor,
  .tertiarycolor, .accentcolor, .bodycolor, .topbarcolor, .toolbarcolor,
  .mapcolor, .hightlightcolor, .shadowcolor) from tab-ui-config.json.
- Strip the matching DOM/CSS-variable override block from Stylize.js;
  Stylize now just applies the selected preset theme (and the page logo).
- Drop the empty bodycolor/topbarcolor/toolbarcolor/mapcolor/shadowcolor
  defaults from API/templates/config_template.js.
- Simplify ThemePreview to render the selected preset directly — no
  Custom branch, no overlay logic.

Preset themes cover all the looks we want and keep the configure surface
much smaller.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(time-ui): round corners on TimeUI shell, action wrappers, mode dropdown

- #timeUI: 10px border-radius on the outer time control bar.
- #mmgisTimeUIActionsLeft / #mmgisTimeUIActionsRight: 10px border-radius
  so the action clusters sit as rounded chips.
- #mmgisTimeUIActionsRight > div (excluding #mmgisTimeUIPresent): 10px
  border-radius on each action button so they match the wrapper.
- #mmgisTimeUIModeDropdown: 40px height + 10px border-radius to align
  with the rest of the bar; clear the dropy default border-color so the
  rounded edge isn't outlined.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260505 [version bump]

* feat(configure): mark light themes as (experimental) in dropdown label

Light themes still have outstanding contrast issues, so flag them in the
Color Theme dropdown without changing the saved value.

- Maker dropdown now accepts options as either a plain string (current
  behavior) or { value, label } so the rendered label can differ from
  the persisted value.
- tab-ui-config switches the six light themes to { value, label } form
  with '(experimental)' appended to the label only. Existing mission
  configs that already saved 'Light Default' etc. continue to match.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix timeUI border radius

* fix(mobile): rescue #timeUI before tool make() destroys it

Clicking Layers -> Time -> Layers -> Time on mobile caused the bottom
panel to render LayersTool content with TimeUI height. The #timeUI DOM
element was destroyed when LayersTool.make() called $('#tools').empty(),
before the async React useEffect in MobileTimeUIToggle could rescue it
to its staging container.

- ToolController_.makeTool: synchronously move #timeUI from #tools back
  to #timeUIMobileStaging (and reset TimeUI store flags) on mobile,
  before invoking the new tool's make().
- MobileTimeUIToggle.handleClick: defensive fallback that re-initializes
  TimeUI if #timeUI no longer exists when the toggle is activated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): move re-initialized #timeUI from staging into #tools

TimeUI.init() on mobile appends the new #timeUI to the hidden
#timeUIMobileStaging container, so the fallback branch must also move
it into #tools — otherwise the user sees an empty tool panel after
the destroyed-element recovery path.

Caught by Devin Review.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): preserve #timeUI when Coordinates tool empties #tools

On mobile, opening or closing the Coordinates tool runs
$('#tools').empty() inside interfaceWithMMWebGIS / separateFromMMWebGIS.
After the previous PR commits, clicking Coordinates -> Time still left
the bottom panel empty because:

- Coordinates.make() empties #tools while #timeUI is in staging (fine
  on its own), but the Coordinates teardown that fires after the user
  switches to the Time toggle (via MobileCoordButton's useEffect on
  activeToolName change) calls Coordinates.destroy() ->
  separateFromMMWebGIS(), which empties #tools wholesale and destroys
  the freshly-placed #timeUI.

Add a rescueMobileTimeUI() helper that moves #timeUI from #tools back
to #timeUIMobileStaging before each tools.empty() call in Coordinates,
mirroring the rescue already done in ToolController_.makeTool().

Coordinates -> Time now correctly shows the TimeUI.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): harden TimeUI fallback recovery (call fina(), de-dupe popovers)

Devin Review correctly flagged that the safety-net path in
MobileTimeUIToggle.handleClick was producing a partially-broken TimeUI
when it fired:

- TimeUI.init() unconditionally appends a new #timeUIPlayPopover_global
  to <body>, so a second init() left two elements with the same id.
- TimeUI.init() alone does not wire up date pickers or per-button click
  handlers — that's TimeUI.fina()'s job. Without fina(), the recovered
  TimeUI rendered visually but Play / Previous / Next / Fit / Follow /
  Present / Expand were all dead.

Before re-initializing, remove the stale #timeUIPlayPopover_global and
#timeUIQuickSelectPopover_global divs to avoid duplicate ids. After the
new #timeUI is moved into #tools, call TimeUI.fina() to populate the
date pickers, attach the button click handlers, build the histogram,
and populate the expanded mobile rows.

Some delegated body/document handlers in attachEvents() will still be
duplicated on this path; that is acceptable for a degraded recovery
that should never run in practice now that the primary rescues in
ToolController_.makeTool() and Coordinates.js cover all known paths.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260505 [version bump]

* fix(mobile): Coordinates teardown only removes its own DOM

The previous Coordinates fix was racing with itself: after the Time
toggle synchronously moved #timeUI into #tools, MobileCoordButton's
useEffect (triggered by the activeToolName change) ran on the next
React tick and called L_.Coordinates.destroy(). That called
separateFromMMWebGIS(), whose rescue moved #timeUI right back into the
hidden staging div before tools.empty() — so the bottom panel ended up
empty even though the time toggle was 'active'.

Make separateFromMMWebGIS selective: only remove the
Coordinates-specific DOM (#coordUIHeader and #CoordinatesDiv) instead
of wiping all of #tools. Any other content already in #tools (e.g.
#timeUI placed there by the Time toggle) is left alone.

interfaceWithMMWebGIS still keeps the rescue + tools.empty() pattern
on the open path so Coordinates always starts from a clean panel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Bump DrawTool Temporal Drawings upward

* chore: bump version to 5.0.6-20260505 [version bump]

* chore: reset version to 5.0.0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test(e2e): fix 9 pre-existing failures (test-only changes)

- mmgis-api.spec.js: add form-fill login under AUTH=local; serialize
  describe to avoid concurrent-login race in the session store
- coordinates.spec.js: TimeUI toggle was moved from the coordinates bar
  to the Settings modal; navigate via topbar kebab menu and assert the
  checkbox is rendered
- widgets.spec.js: target .leaflet-control-zoom-in/-out specifically;
  the bare .leaflet-control-zoom class is also used by the home/reset
  control, so the original assertion was always false
- sites.spec.js: scope panel selector to #toolPanel; both the toolbar
  icon and the panel container share id="SitesTool"

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* Revert "chore: bump version to 5.0.1-20260505 [version bump]"

This reverts commit 4880204c1163be5d1d7fa96d14a0ed018c6f586c.

* fix: prevent filter operator dropdown clipping in Layers panel

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260507 [version bump]

* revert: keep dropy openUp:true for operator dropdowns

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit d67c369ed437e47d658ae051348d377978dc48ed.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 29565ed829a55e9c241a789c9a3901d11cb5ca67.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 50e357604ebe9378564619b34c508b63cfb62c1d.

* chore: bump version to 5.0.1-20260507 [version bump]

* chore: bump version to 5.0.2-20260511 [version bump]

* fix: render Globe panel immediately on first open without window resize

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260511 [version bump]

* feat: add theme borders to panels and gradient backgrounds to splitters

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: bump split shadow gradient opacity to 0.4

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal 3-col grid + smaller leaflet zoom button gap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: prevent hotkey label/value wrapping (ellipsis instead)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal single column, no wrap, no truncation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: hotkeys modal dividers, invert title/subtitle colors, rename title, margin above subtitles

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: move splitter gradient to themed CSS class, restore hover feedback

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* style: hotkeys section titles use --color-h (matches rest of app)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* fix: guard Globe_.init() inside rAF to prevent double instantiation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.6-20260512 [version bump]

* feat(plugins): per-plugin deps, lazy tool loading, validation, shared discovery

Phase 3 — Plugin config validation + override warnings:
- New API/pluginValidation.js with validatePluginConfig() for tool, component,
  and backend manifests. Validates required fields (name, paths), object/string
  shape of paths, dependencies block (npm/python.pip/python.conda), and warns
  on unknown top-level fields.
- updateTools()/updateComponents() now skip invalid plugins and emit override
  warnings (matching what components already logged for tools).

Phase 2 — Shared discoverPlugins() utility:
- New API/pluginDiscovery.js consolidates the duplicated scanning logic from
  updateTools(), updateComponents(), and getBackendSetups(). Supports exact-
  name and substring container patterns, JSON/require/no-op loaders, and skips
  dot/underscore-prefixed dirs.
- updateTools.js and setups.js refactored on top of the shared helper.

Phase 1 — Per-plugin dependency declaration + build-time aggregation:
- Plugin config.json may now declare a 'dependencies' block (npm + python.pip +
  python.conda). validatePluginConfig() also validates this shape.
- New scripts/resolve-plugin-deps.js scans every tool/component/backend plugin
  and writes plugin-package.json, plugin-python-requirements.txt, and
  plugin-conda-deps.txt. Detects version conflicts and fails loudly.
- scripts/build.js calls resolvePluginDeps() before updateTools().
- Dockerfile installs the aggregated plugin npm and pip deps after the root
  npm ci, using --no-save / --no-package-lock / --ignore-scripts so the root
  lockfile is untouched.
- Animation tool migrated: ffmpeg/gifshot/html2canvas now declared in its
  config.json (kept in root package.json for transitional compat).
- Generated artifacts gitignored.

Phase 4 — Lazy loading of tool bundles:
- updateTools() now emits dynamic-import arrow functions in the generated
  src/pre/tools.js with webpackChunkName hints so each tool is split into
  its own chunk (Kinds stays static because it's required synchronously).
- ToolController_ gains ensureToolLoaded(name) and getLoadedTool(name) helpers
  and makeTool is async; init/finalizeTools and the separated-tool auto-open
  flow are updated to handle lazy modules.
- Toolbar.jsx, SeparatedTools.jsx, SitesTool.js, and Layers_.js migrated to
  resolve LayersTool/etc. via the new helpers instead of poking toolModules
  directly.

Tests & docs:
- tests/fixtures/test-plugin-tools/{TestPlugin,InvalidPlugin,OverridePlugin}
  + tests/helpers/plugin-helpers.js with install/uninstall helpers.
- New unit specs: pluginValidation, pluginDiscovery, updateTools,
  resolvePluginDeps, toolLazyLoading (57 tests, all passing).
- CONTRIBUTING.md and docs/pages/Contributing/Contributing.md updated with
  schema, override behaviour, dependency declaration, build-time aggregation,
  conflict detection, and Docker integration.

* chore: bump version to 5.0.7-20260512 [version bump]

* fix: make Globe_.init() idempotent against multi-init

Globe_.init() previously constructed a fresh GlobeRenderer on every call,
which after #71 could happen multiple times for a single toggle (uiStore
setTimeout + TopBar rAF). Each extra construction appends another
.cesium-widget / _lithosphere_scene to #globe and leaves event handlers
wired to dereferenced renderer state, which has been observed to break
LithoSphere globe control buttons on configurations where the globe panel
starts closed at boot.

Add a top-of-init() guard that bails out and calls invalidateSize() when
a renderer already exists. Single small, surgical change; no behavior
change for the !L_.hasGlobe mock-swap path or for first-time construction.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.7-20260512 [version bump]

* test(plugins): generate src/pre/tools.js on demand in toolLazyLoading spec

The Playwright unit-tests CI step runs before `npm run build` so the
gitignored `src/pre/tools.js` artifact does not yet exist on disk.
Add a beforeAll hook that invokes `updateTools()` to regenerate it
when missing, keeping the spec self-contained on both CI and dev
machines that already built locally.

* fix(tools): defensive getTool() + preload flag for cross-referenced tools

Devin Review flagged a behavioural regression introduced by Phase 4:
`ToolController_.getTool(name)` previously always returned a method-
callable object (real module or `{ use(){} }` stub) because every tool
was statically imported. After Phase 4, unresolved lazy loaders are
`() => import(...)` functions, so callers like `Map_.getTool('InfoTool').use(...)`,
`mmgisAPI.getTool('DrawTool').filesOn`, and `LegendTool` calling
`LayersTool.populateCogScale` would crash with TypeError until the
target tool was opened.

Two fixes:

1. **Defensive getTool()**: Returns the legacy fallback stub when the
   tool module is still a lazy-loader function, and fires off
   `ensureToolLoaded(name)` in the background so subsequent calls see
   the resolved module. Prevents all crashes immediately.

2. **`preload: true` config flag**: Tools reached synchronously from
   other code paths (Info, Draw, Layers, Chemistry) now declare
   `"preload": true` in their `config.json`. `ToolController_.init()`
   calls `preloadEagerTools()` which fires `ensureToolLoaded` for
   every such tool right after toolbar setup — the chunks download
   in parallel with the rest of the page becoming interactive, so by
   the time a user clicks a feature the InfoTool module is already
   resolved.

`validatePluginConfig` now accepts `preload` as a known tool field;
CONTRIBUTING.md and docs/pages/Contributing/Contributing.md updated to
document when to set it. Added a unit test covering the defensive
getTool behaviour and the `preload` propagation through
`toolConfigs`.

* chore: bump version to 5.0.8-20260512 [version bump]

* revert(plugins): remove Phase 4 lazy tool loading and preload mechanism

Phase 4 lazy emission caused cross-tool consumers (Map_ feature-click,
mmgisAPI, LegendTool) to receive raw '() => import(...)' arrows from
ToolController_.getTool(), breaking InfoTool open. Reverting to the
pre-Phase-4 behavior of static tool imports.

- API/updateTools.js: generated src/pre/tools.js now emits
  'import FooTool from ...' for every tool (Kinds stays static too).
- ToolController_.js: getTool/makeTool back to sync; ensureToolLoaded,
  getLoadedTool, preloadEagerTools deleted; separated-tool auto-open
  flow simplified to direct sync calls.
- Toolbar.jsx, SeparatedTools.jsx, Layers_.js: revert async/lazy
  patterns to sync ToolController_.toolModules[name] access.
- API/pluginValidation.js: drop 'preload' from KNOWN_FIELDS.
- src/essence/Tools/{Info,Draw,Layers,Chemistry}/config.json: drop
  'preload: true'.
- CONTRIBUTING.md + docs: remove preload documentation.
- tests/unit/toolLazyLoading.spec.js: rewrite to verify static
  imports instead of lazy loaders.

Also: log standard backends at startup (parity with plugin backends
and with tools/components), so all backends now produce
'info  Loaded backend: <name> from <container>' at boot.

Phases 1-3 (per-plugin dependency aggregation, shared discoverPlugins,
config validation + override warnings) are unaffected.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(logger): new 'loaded' level (purple bg) for tool/component/backend startup

Previously the 'Loaded tool/component/backend: X from Y' lines used
the generic blue 'info' tag. They now use a dedicated 'loaded' level
rendered with a purple (#a855f7) background, so plugin discovery
output is visually distinct from other info messages.

- API/logger.js: add 'loaded' case to the dev-mode switch (white text
  on purple bg) and suppress the redundant 'Caller:' echo for it
  (matches how 'info' and 'success' are handled).
- API/updateTools.js: registerPlugin now logs at level 'loaded'.
  Drops the redundant 'Loaded ' prefix since the level tag now reads
  'loaded'.
- API/setups.js: standard and plugin backend startup logs use the
  new level, same drop of the 'Loaded ' prefix.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(logs): 'Plugging in Tools/Components/Backends...' headings

Rename the cyan banner messages in scripts/build.js and scripts/server.js
from 'Updating Tools...' / 'Updating Components...' to 'Plugging in
Tools...' / 'Plugging in Components...' so the headings match the
plugin terminology used everywhere else (plugin-package.json,
discoverPlugins, etc.).

Also add a matching 'Plugging in Backends...' banner before
setups.getBackendSetups() in scripts/server.js so backends get an
equivalent title block to tools and components.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(logs): cyan banner lines lead with a blank line instead of trailing one

Move the \n from the end to the beginning of every cyan banner in
scripts/build.js and scripts/server.js (Resolving Plugin Dependencies,
Plugging in Tools/Components/Backends, Validating Environment
Variables, Starting websocket, Starting the development server) so
that the blank line visually separates each section above its title
rather than below it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(plugins): postinstall hook auto-installs plugin npm deps

Plain `npm install` (or `np…
…ectors (#981)

* Remove dead CSS: delete tools.css, clean ~600 lines from mmgisUI.css and mmgis.css

- Delete tools.css entirely (both selectors #CurtainToolList and
  .searchToolSelect are unreferenced anywhere in the codebase)
- Remove from mmgisUI.css: .mmgisRadioBar3/4/Vertical (140 lines),
  .mmgispureselect (104 lines), blink/condemned_blink_effect (38 lines),
  .slidecontainer/.slider (41 lines), .ar_slider (91 lines),
  .verticalSlider (91 lines), .mmgisMultirange_elev (19 lines),
  .ui-corner-all/bottom/right/br (9 lines)
- Remove from mmgis.css: #nodeenv, empty #topBar{}, #topBarInfo,
  #topBarHelp, #topBarFullscreen, #toggleUI, #logoGoBack
- Keep #topBarLink (used in BottomBarReact.jsx), #webgl-error-message
  (used by vendored THREE.js)
- All selectors verified with repo-wide grep before removal

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI fixes: tooltips, splitter hover, mobile toolbar, color schemes

- Replace Base UI Tooltip with simple React portal tooltip (200ms delay,
  tippy-matching style) — fixes missing tooltips for toolbar/topbar/bottom buttons
- Add cursor + hover highlight to vertical splitters (was missing because
  module CSS didn't inherit global .splitterV styles)
- Add hover highlight to tool panel drag handle
- Remove mdi-drag-vertical icon from tool panel drag
- Add mobile toolbar horizontal layout via @media query overrides
- Add 4 new color schemes: High Contrast (a11y), Dark Mars, Dark Midnight,
  Light Warm (total: 10 themes)
- Previous fixes also included in working tree:
  - timeUI border moved to toolsWrapper border-bottom (conditional)
  - #toggleTimeUI button removed entirely
  - CoordinatesDiv: vertical centering, unified background, 12px right offset
  - barBottom padding-bottom: 8px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix assignment operator used instead of comparison in TimeControl.fina()

Pre-existing bug: `TimeControl.enabled = true` was assigning instead of
comparing. Changed to `TimeControl.enabled === true`.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restructure Configure page UI tab: add all themes, Custom mode, enableWhenField

- Added all 10 theme presets to dropdown (was missing Dark Mars, Dark Midnight,
  Light Warm, High Contrast)
- Added 'Custom' option: skips preset theme, uses only color picker values
- Moved Theming section directly under Rebranding
- Nested 'Custom Color Options' under Theming with subdescription
- Added enableWhenField support to Maker.js: disables color pickers unless
  theme is set to Custom
- Renamed color options with clearer names and improved descriptions:
  Primary → Surface Color, Secondary → Deep Background Color,
  Tertiary → Text Color, Body → Page Body Color, Highlight → Feature Highlight
- Stylize.js: skip setTheme() when theme is 'Custom'
- Rebuilt configure page

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert tooltips to tippy.js, fix dropdown menus, redesign About modal

1. Tooltip: Replaced custom React portal tooltip with tippy.js wrapper.
   Uses the existing tippy.js dependency and 'blue' theme for consistency.

2. Dropdown: Replaced Base UI Menu with native portal dropdown.
   Base UI's nested Menu.Trigger + BaseButton composition was swallowing
   click events, breaking userAvatar and menuBtn menus. New implementation
   uses simple state + createPortal with proper outside-click dismissal.

3. About modal: Professional redesign with centered MMGIS ASCII art header,
   proper GitHub SVG logo link, clean metadata section, centered link
   buttons, attributions section, and NASA-AMMOS footer.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore .mmgisHelpButton base styles lost during Help.css module migration

The global .mmgisHelpButton styles (yellow color, compact 18x18px sizing,
0.7 opacity) were removed when Help.css was converted to Help.module.css.
Since Help.getComponent() emits raw HTML strings for jQuery-rendered tool
headers, it cannot use CSS Module scoped classes. Restored the base styles
in mmgis.css alongside the related .mmgisToolHelpBtn definition.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix session logout regression, About modal refinements, High Contrast theme, Stylize.js, Default Tool config

- Login: skip session.regenerate() for token-based re-auth (useToken:true)
  so reloading the main page no longer invalidates the configure page session
- About modal: replace ASCII art with mmgis.png logo, rename Attributions to
  Map Layer Attributions, remove footer logo, link NASA-AMMOS to ammos.nasa.gov
- High Contrast theme: change accent from #ffff00 to #ffd700 (gold) for better
  contrast ratios against dark backgrounds
- Stylize.js: color overrides only apply when theme is Custom or unset,
  preventing preset themes from being clobbered by stale config values
- Restore Default Tool config section in tab-ui-config.json (accidentally
  removed during Theming section reorganization)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore defaulttooldropdown case handler in Maker.js

The case was accidentally removed during the Configure page UI tab
restructure (d7f96c50). Without it, the Default Tool dropdown in the
Configure page rendered as nothing despite the config referencing it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* High Contrast tooltip text, panel toggle styling, scale position swap

- High Contrast theme: tooltips now use black text on yellow background
  via --color-c2-text variable (white for all other themes)
- About modal links use var(--color-f) for consistent theme text color
- Panel toggle buttons: 11px uppercase with 600 weight for better
  visibility
- Mapping scale button moved to bottom-right of compass (was top-left)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Reposition Viewer and Globe panel buttons to top-right

- Viewer: dropdown selector at top-right edge, OSD buttons stacked
  vertically below it; settings panel opens to the left
- Globe: home, exaggerate, observe, walk, link controls moved from
  TopLeft to TopRight corner via addControl 4th arg
- Style consistency: OSD buttons and LithoSphere controls now match
  Leaflet zoom controls (var(--color-a) bg, var(--color-f) text,
  var(--color-mmgis) hover, 30px size, 3px border-radius)
- Viewer settings sliders use var(--color-a3) instead of hardcoded
  #444444
- Az/el indicator stays at bottom center (exception per design)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Consistent modal theming + session security fix

Modal theming:
- All modals now share consistent styling: backdrop-filter blur, semi-transparent
  background via --color-a-rgb, 10px border-radius, header divider line, box-shadow
- Updated: loginModal, Help, ConfirmationModal, Settings, Hotkeys, About modals
- Tool panel backgrounds changed from opaque var(--color-k) to transparent so the
  ToolPanel's existing backdrop-filter effect shows through
- Legend tool header updated to match consistent 44px height with divider
- applyTheme.js now auto-derives --color-a-rgb from theme's --color-a hex value
- Modal service wrapper gets backdrop-filter: blur(12px)

Session security (Devin Review fix):
- Token re-auth now calls req.session.regenerate() with data preservation to
  prevent session fixation while maintaining multi-tab compatibility
- Token is rotated via crypto.randomBytes on every re-auth (was being reused)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI fixes: viewer settings, button sizing, menu contrast, coords, login header

1. Viewer OSD settings moved to top of button stack, panel opens downward
2. Overlay buttons consistent 30x30px (OSD line-height fix, home button)
3. Menu/icon contrast improved: Dropdown items and IconButtons use --color-a5
   (was --color-a3) with --color-f on hover for better dark theme legibility
4. CoordinatesDiv fixed to 30px height, pickLngLat button centered
5. Login modal now has a header bar with 'Log In' title and close X button;
   title toggles to 'Sign Up' when switching modes

Also reverts session regeneration for token re-auth (Devin Review feedback):
token-based re-auth now refreshes session data in-place without regeneration
or token rotation, preserving multi-tab compatibility.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* UI polish: nav popover, sep tools, compass, zoom controls, status indicator, toast

1. Description nav popover: added z-index:9000 so menu appears above map panels
2. Separated tools: default color changed from accent to --color-f; fixed CSS
   selector from .toolButtonSep to .toolSep to match actual class names
3. Compass + mapping scale shifted left by 30px for better positioning
4. Map zoom/home controls: use --color-f instead of accent --color-c to reduce
   visual prominence; hover still highlights with accent color
5. Status indicators (reload/ws disconnect/layer update) moved from Leaflet
   control to TopBar with soft pulsing fade animation and tooltip on hover
6. WebSocket retry toast: rounded corners, glass background with backdrop-filter,
   border-left accent for failure state instead of solid red background

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix 14 tool UI issues: headers, backgrounds, layout, functional bugs

Header alignment:
- LayersTool: align-items center on #filterLayers, gap between right icons
- InfoTool: align-items center on #infoToolHeader, 44px height
- ViewshedTool: align-items center, restructured header with left/right divs
- IsochroneTool: restructured flat header into nested mmgisToolHeader pattern
- ShadeTool: align-items center on #vstHeader children

Icon spacing:
- LayersTool: increased right margin to 28px + gap 2px
- ViewshedTool: #vstNew padding-right 30px (clear of close button)
- IsochroneTool: #iscNew padding-right 30px

Missing components:
- SitesTool: added Help import + help icon via mmgisToolHeader pattern
- AnimationTool: added full mmgisToolHeader with title and help icon

Background fixes:
- InfoTool: changed toolsContainer background from transparent to var(--color-a)
- DrawTool: changed toolsContainer background from transparent to var(--color-a)

Layout fixes:
- DrawTool: #drawToolContents top 81px, height calc(100%-81px), #drawToolNav margin-right 0
- MeasureTool: removed padding-left:0 override from mmgisToolHeader child selector

Functional fixes:
- InfoTool: updated jQuery selectors from #InfoTool to #toolButtonInfo (React toolbar IDs changed)
- CurtainTool: deferred OpenSeadragon init with requestAnimationFrame (React 18 async render)
- CurtainTool: curtainToolBar justify-content flex-end (icons at bottom)

Security:
- TopBar StatusIndicator: escape HTML in layer names to prevent XSS via addLayerQueue

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix mapToolBar pointer events, login padding, default tool, About modal order

1. mapToolBar: set pointer-events:none on both #mapToolBar and direct children
   so clicks pass through to the map; leaf elements still get auto via
   .childpointerevents rule
2. #loginModalBody: padding changed to 40px 0px 0px
3. Default tool: deferred click to requestAnimationFrame so React toolbar
   has rendered before getElementById runs
4. About modal: moved mainInfoModalCustom to right below mainInfoModalHero

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix tool headers to 40px, ViewshedTool subheader, AnimationTool header, InfoTool close btn, statusIndicator position

1. All tool panel headers: changed from 44px to exactly 40px height
   - Global .mmgisToolHeader and .mmgisToolTitle in mmgis.css
   - InfoTool.css, ViewshedTool.css, IsochroneTool.css, ShadeTool.css

2. LayersTool #filterLayers: height 40px, .right > div height unset,
   .right margin-right 30px, .right > div margin 0px 3px

3. ViewshedTool: restructured header — title+help in mmgisToolHeader row,
   vstToggleAll (left) and vstNew (right) on a new #vstSubHeader row below

4. AnimationTool: removed old #animationToolHeader CSS (padding 15px 20px,
   white background, 18px font), now uses standard mmgisToolHeader class.
   Fixed color from var(--color-a) (background) to var(--color-f) (text)

5. InfoTool close X: re-inject close button after use() rebuilds content
   via TC_.injectCloseButton() (toolsContainer.empty() was removing it)

6. StatusIndicator: moved to left of topBarTitle in JSX render order.
   Added align-items:center to #topBarMain for vertical alignment.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Remove title attr from StatusIndicator (conflicts with tippy tooltip)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TimeUI dropdown z-index above tool panel, reposition toasts to top-center

1. TimeUI dropdowns: added z-index 10000 to all dropy content ul elements
   so they render above the vertical tool panel (z-index 1400). Also set
   timeUIDock to position:relative with z-index 10000 and overflow:visible.

2. Toast notifications: repositioned #toast-container from bottom-right to
   top-center just below the topbar (top: 44px, centered with transform).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix StatusIndicator spacing, CurtainTool close btn, header title font consistency

1. StatusIndicator: use display:none/flex instead of opacity:0/1 so it
   takes no space when there's no active status indicator.

2. CurtainTool: added close X button at top of curtainToolBar (matching
   MeasureTool pattern) with flex spacer pushing other buttons to bottom.

3. Header title font consistency: InfoTool, ShadeTool, CurtainTool titles
   now match mmgisToolTitle standard (font-weight:600, padding-left:10px,
   height:40px for CurtainTool which was 34px).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Legend empty state, scalefactor position, sep-tool-header unbold

1. Legend: show 'No active layers with legends' when no legend items
   are present. Also fixed container height calc(100% - 40px).

2. Mapping Scale (.leaflet-control-scalefactor): shifted 10px right
   (left 26→36px) and 1px down (bottom 30→29px).

3. sep-tool-header span: font-weight changed from 600 to 400.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Guard Legend empty state message to only show when panel is active

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TimeUI dropdown covered by toolPanel: remove splitscreens stacking context

#splitscreens had z-index:1 which created a stacking context, confining
its children (including bottomFloatingBar at z-index:1500) to that context.
Since ToolPanel (z-index:1400) was a sibling outside splitscreens, it
painted above all splitscreens children regardless of their internal
z-index values.

Fix: change #splitscreens z-index from 1 to auto so it no longer creates
a stacking context. Now bottomFloatingBar (1500) participates in the
same stacking context as ToolPanel (1400), and 1500 > 1400 means the
TimeUI dropdown correctly paints above the tool panel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Migrate ~69 CursorInfo toast-like calls to proper Toast component

- Replace CursorInfo.update() toast-like calls with Toast.info/success/warning/error
- Preserve message colors: blue→info, green→success, yellow→warning, red→error
- Keep 12 legitimate cursor-following CursorInfo calls unchanged
- Files migrated: DrawTool.js, DrawTool_Files.js, DrawTool_FileModal.js,
  DrawTool_Templater.js, DrawTool_SetOperations.js, DrawTool_Drawing.js,
  DrawTool_Editing.js, DrawTool_Shapes.js, LayersTool.js, ShadeTool.js,
  chemistrychart.js
- Fix Devin Review: Change misleading 'Bad token' to 'Login failed' in users.js
- Normalize line endings (CRLF→LF) in affected files

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Toast.js missing from git, CoordinatesDiv z-index, Legend duplicate ID, topBar padding

- Add Toast.js to version control (was untracked, causing webpack module error)
- Bump CoordinatesDiv z-index from 20 to 1001 (was hidden behind splitscreens
  children after z-index:auto change)
- Fix Legend duplicate ID: separated tool icon was #LegendTool, same as content
  container div, causing empty message to appear in button instead of panel
- Add hasStatus class to #topBarMain when statusIndicator is active, setting
  #topBarTitleName padding-left to 0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Legend empty message: scope selector to content container via targetId

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Move Toast.js to design-system, fix --color-a3 text contrast, update AGENTS.md

- Move Toast.js from UserInterface_/components/Toast/ to design-system/components/Toast/
  (generic component belongs in design-system, not MMGIS-specific UserInterface_)
- Update all 11 Toast import paths to new location
- Bump --color-a3 in 5 dark themes to pass WCAG AA 4.5:1 contrast for text:
  Dark Default #747c81→#81888d, Dark Blue #64748b→#738399,
  Dark Warm #8b7a5e→#908064, Dark Mars #8a6a60→#98796f,
  Dark Midnight #606088→#7a7a9e
- Update AGENTS.md: document design-system/ vs UserInterface_/ distinction
  in project structure and Key Directories

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix topBarTitleName padding override: increase specificity and add !important

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix IdentifierTool deactivation: update icon ID reference in separateFromMMWebGIS

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Hide toolPanelDrag when no tool is open

- Add setToolPanelDragVisible(false) to closeActiveTool() (was only in makeTool toggle-off path)
- Also guard drag handle display on isOpen (toolPanelWidth > 0) as safety net

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add hover effect to MMGIS logo (opacity + brightness transition)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix MMGIS logo hover: keep full opacity, use subtle background highlight instead

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Selective tile fade: fade on pan/zoom, instant on refresh/reload

- Remove blanket _fadeAnimated toggle from toggleTimeUI (was killing fade
  for all tiles while TimeUI was open)
- Monkey-patch GridLayer.redraw, TileLayer.setUrl, and GridLayer._tileReady
  to suppress fade via a transient _suppressTileFade map flag
- Set _suppressTileFade in reloadTimeLayers for time-driven reloads
- Flag auto-clears after 300ms so pan/zoom tiles still get the nice fade
- Install pbf dependency (required by CesiumMVTLayer from #942 merge)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Per-layer fade control: time-enabled + shade/viewshed layers never fade

- Replace transient map-level _suppressTileFade with per-layer _noFade flag
- Patch GridLayer._tileReady to check _noFade on the layer instance
- Set _noFade on time-enabled tile layers and data/GL layers at creation
- Set _noFade on Shade and Viewshed tool GL layers
- Non-time-enabled base imagery tiles still fade normally on pan/zoom

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile UI improvements — move hamburger to right menu, show panel toggles, isMobile-driven toolbar layout, desktop-matching scalebar/compass

- Remove left hamburger menu (#topBarMenu), move BottomBar items into
  top-right kebab dropdown menu for both mobile and desktop
- Show panel toggles (Viewer/Map/Globe) and account/login UI in mobile
  topbar's #topBarRight
- Move toolbar horizontal layout CSS from @media breakpoints to
  UserInterfaceMobile_.css (loaded only when isMobile flag is true)
- Remove #mapTopBar @media rule from mmgis.css, add to mobile CSS
- Remove mobile-only simplified scalebar rendering; use full desktop
  scalebar with both large and small axes on all viewports
- Remove display:none on .leaflet-control-scalefactor in mobile CSS
- Remove #loginDiv display:none from mobile CSS (React overlay handles it)
- Simplify BottomBarReact container styles (no more absolute positioning)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260430 [version bump]

* fix: mobile toolbar 40px height, bottomFloatingBar flush, timeUI toggle, scalebar position, hide hotkeys

- Toolbar height 40px, toolButton width 40px, no border-bottom
- toolcontroller_incdiv: no padding-bottom, overflow-y hidden
- bottomFloatingBar: no border-radius, left/right/bottom = 0
- Add MobileTimeUIToggle button on far right of toolbar
- Hide Keyboard Shortcuts from kebab menu on mobile
- Fix scalebar positioning (remove top:48px override in UserInterfaceBridge)
- Set mobileTopSize/topSize to 40 (splitscreens top = 40px, not 50px)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile topBar padding-left 34px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: update MobileCoordButton topBar paddingLeft from 80px to 34px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: MobileTimeUIToggle — inline toggle logic, float right, hide from settings on mobile

- Replace broken Coordinates.toggleTimeUI() call with direct jQuery/store toggle
- Float time button right in toolbar
- Hide Time UI toggle from settings modal on mobile (toolbar has it)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: push scalebar/compass/scale up 40px on mobile, keep #timeUI in DOM

- BottomElementPositioner: position mapToolBar, leaflet-bottom-left/right
  40px above bottom on mobile (above toolbar)
- Stop removing #timeUI from DOM on mobile so MobileTimeUIToggle works

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — only show endtime, always expanded

- Hide #mmgisTimeUIStartWrapper and StartWrapperFake on mobile via CSS
- Force expanded state (addClass expanded + show) when toggling TimeUI on
- CSS ensures #timeUI.active always shows expanded content on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI opens in tool panel with header, end time, expanded rows

- MobileTimeUIToggle now opens/closes the tool panel via ToolController_
- Closes any active tool before showing TimeUI
- Forces expanded state when opening
- CSS hides start time inputs, positions expanded content properly
- Overrides absolute positioning of expanded content for tool panel flow

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: rewrite separated tools system from jQuery to React components

- Add separatedToolsList/activeSeparatedTools state to Zustand uiStore
- Rewrite SeparatedTools.jsx with glassmorphism panels, CSS Module styling
- Replace SepToolsContainer (setInterval hack) with SepToolButton/SepToolsSection
- Remove ~170 lines of jQuery DOM construction from ToolController_.js
- Fix hardcoded rgba(26,26,27,0.88) to theme-aware var(--color-a-rgb)
- Remove separated tool entries from themeApplier.js
- Remove separated tool overrides from FloatingElements.css
- Move Legend CSS overrides from Toolbar.module.css to SeparatedTools.module.css
- Remove jQuery active-state manipulation from IdentifierTool.js
- Add store sync in Map_.js displayOnStart logic
- Preserve all DOM IDs for backward compatibility (mmgisAPI, tool make())

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260501 [version bump]

* fix: TimeUI mobile checks — use Zustand store instead of L_.UserInterface_

L_.UserInterface_ is null when TimeUI.init() runs (TimeControl.init is called
before L_.link sets UserInterface_). All 16 isMobile checks now read from
useUIStore.getState().isMobile which is set at startup.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: move displayOnStart logic from Map_.js to ToolController_.finalizeTools()

- Map_ no longer references specific tools (LegendTool)
- displayOnStart is now handled generically for all separated tools
- Added DOM element polling (tryMake) to handle React render timing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* revert: remove all TimeUI-related mobile changes

Reverts TimeUI.js and BottomBar.js to development base.
Restores #timeUI DOM removal in UserInterfaceBridge.fina().
Removes MobileTimeUIToggle component from Toolbar.jsx.
Removes TimeUI mobile CSS overrides from UserInterfaceMobile_.css.
Non-TimeUI refinements (toolbar height, scalebar positioning, etc.) preserved.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* simplify: remove DOM polling, use simple setTimeout(0) for auto-open

LegendTool handles its own content lifecycle via subscribeOnLayerToggle.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: mobile TimeUI — fix isMobile detection, staging container, toolbar toggle

- TimeUI.js: import useUIStore and replace all 16 L_.UserInterface_?.isMobile
  checks with useUIStore.getState().isMobile (L_.UserInterface_ is null when
  TimeUI.init() runs, so mobile conditionals were dead code)
- TimeUI.js: stage mobile #timeUI in hidden #timeUIMobileStaging instead of
  placing directly in #tools (which gets cleared by other tools)
- UserInterfaceBridge.js: stop removing #timeUI from DOM on mobile
- Toolbar.jsx: add MobileTimeUIToggle that moves #timeUI between staging and
  #tools, opens/closes tool panel via ToolController_
- BottomBar.js: hide TimeUI toggle from settings modal on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: rescue #timeUI back to staging when another tool opens

Subscribe to activeToolName changes — when a tool becomes active while
TimeUI is showing, move #timeUI back to #timeUIMobileStaging before
the new tool's make() clears #tools.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove separatedTool/justification config toggles, fix review issues

- Remove separatedTool checkbox and justification dropdown from Legend
  and Identifier config.json (these are always separated, not configurable)
- Remove justification property/code from LegendTool.js, IdentifierTool.js
- Simplify Globe_.js separated tool count (no justification filter)
- Remove justification from Reference-Mission config blueprint
- Update LegendTool help docs and Legend.md documentation
- Add --color-a-rgb fallback (29,31,32) in SeparatedTools.module.css
- Add display:none !important to .panelIdentifier to prevent 12px gap
- Update e2e test comment

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: circular import in TimeUI.js, toolbar/bottomFloatingBar position sync

- TimeUI.js: replace top-level useUIStore import with lazy _getUIStore()
  accessor to avoid 'Cannot access useUIStore before initialization'
  circular import error at _remakeTimeSlider
- SplitScreens.jsx: skip #timeUI reparenting observer on mobile (mobile
  uses MobileTimeUIToggle to manage #timeUI placement in #tools)
- BottomElementPositioner.jsx: unify mobile transition to 0.3s (matches
  toolsWrapper and toolbar), guard pxIsTools against undefined
- Toolbar.jsx: align toolbar transition to 0.3s ease-out

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* LegendTool fix empty message

* chore: remove separated tools offset logic from Globe_.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: skip _makeHistogram on mobile (no timeline slider, timestamps unset)

_makeHistogram renders inside the timeline slider which doesn't exist
on mobile. Without it, _timelineStartTimestamp is NaN, causing
'Invalid time value' RangeError at toISOString().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — populate expanded rows, fix Invalid date, fix panel height

- TimeUI.js attachEvents: use _initialStart/_initialEnd on mobile (same
  as desktop) instead of L_.TimeControl_ which isn't set yet at init time.
  Fixes 'Invalid date' in start/end time inputs.
- TimeUI.js fina: set expanded=true on mobile and call _populateExpandedRows()
  so year/month/day/hour rows actually render. Removed position:absolute and
  pointer-events:none overrides.
- Toolbar.jsx: set tool panel height to 217px (TimeUI.height) instead of
  45% viewport — matches actual TimeUI content height.
- UserInterfaceMobile_.css: expanded content flows naturally (position:relative),
  hide start time inputs, allow overflow scroll, flex-wrap topbar.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI justify-content center, restore toolbar border-bottom

- Add justify-content: center to #mmgisTimeUIMain on mobile
- Remove border-bottom: none override so toolbar keeps its default border

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI overflow hidden, scalebar/compass fixed at 40px offset

- #timeUI overflow-y: hidden (was auto, causing 2px scroll)
- Scalebar/compass/map controls stay at fixed 40px offset (above toolbar)
  regardless of tool panel state — no longer shift up by pxIsTools

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Implement multi-tier knowledge architecture

- Restructure AGENTS.md from 745 lines to 106 lines (Tier 1: essential context)
- Create knowledge/ directory with 30+ wiki-style documentation files (Tier 2: deep knowledge)
- Create knowledge/reference/ with 8 detailed reference files (Tier 3: lookup material)
- Move AI-GETTING-STARTED.md and AI-DEVELOPMENT.md to knowledge/
- Update all file references in .specify/templates and blueprints
- Create knowledge/README.md as the full knowledge base index
- Create knowledge/reference/README.md as reference material index

Three-tier knowledge discovery system:
  Tier 1: AGENTS.md (~106 lines) - scannable in <2 minutes
  Tier 2: knowledge/*.md - deep knowledge on architecture, tools, APIs, DB, infra
  Tier 3: knowledge/reference/*.md - coding conventions, API reference, troubleshooting

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: mobile toolbar active button style matches desktop, fix icon alignment

- All mobile toolbar buttons (ToolButton, MobileCoordButton, MobileTimeUIToggle)
  now use display:flex with align-items/justify-content center for proper
  vertical icon centering
- MobileCoordButton: changed 'active' class to 'toolButtonActive' to match
  the global CSS active style (color-mmgis + color-i background)
- Removed inline color overrides so CSS .toolButtonActive takes effect

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add Devin knowledge notes from past MMGIS sessions

Include curated lessons learned from past Devin sessions:
- CI/CD: ignore build-arm64/amd64 failures, focus on required checks
- Child sessions: no separate PRs when consolidating
- ENV triple-update rule (.env, sample.env, ENVs.md)
- Error handling: use logger with infrastructure_error for fatal startup errors
- Path traversal security: stay within /Missions, handle subpath serving
- Database initialization architecture and migration patterns
- API authentication behavior across AUTH modes
- Auto-generated MMGIS concept index

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar active button style, icon alignment, tool deactivation

- Active toolbar buttons get desktop-matching margin (1px 0) and
  border-radius (8px) via .toolButton.toolButtonActive CSS rule
- Removed line-height: 40px from .toolButton (flex centering handles
  vertical alignment, line-height was pushing icons up)
- MobileCoordButton now watches activeToolName store and deactivates
  when another tool opens (fixes coords staying active)
- MobileTimeUIToggle sets activeToolName='MobileTimeUI' when opening
  so coords/other buttons can detect it and deactivate
- MobileTimeUIToggle clears activeToolName when closing
- Both custom buttons skip self-deactivation via name check

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix cross-references: convert backtick refs to markdown links, add Devin knowledge notes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon height 40px, button margins for active padding

- #toolbar .toolButton i: height 40px fixes icon vertical alignment
- #toolbar .toolButton: margin 0 2px gives spacing between buttons
- #toolbar .toolButton.toolButtonActive: margin 1px 2px so active
  background has visual padding around the icon

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename knowledge/ to .knowledge/ for consistency with .specify/ convention

Dot-prefix signals agent infrastructure (not source code), consistent with
.specify/, .github/, .vscode/ conventions. All cross-references updated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon line-height 40px, active button padding via height

- Coord and TimeUI button <i> icons get line-height: 40px
- Active buttons: height 34px (vs 40px toolbar) creates visual padding
  around the active background, centered by flex align-items
- Buttons get margin: 0 1px for horizontal spacing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix broken cross-reference: 06.2 -> 06.1-configure-rest-api.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: close active tool + cancel deferred cleanup in MobileCoordButton/TimeUI

- MobileCoordButton: call closeActiveTool() before opening, destroy
  _pendingCloseTool if set, increment _closeSeq to cancel deferred
  tools.innerHTML clear
- MobileTimeUIToggle: same _pendingCloseTool + _closeSeq fix after
  closeActiveTool() to prevent 420ms deferred cleanup from wiping
  #timeUI after it's placed in #tools
- Removed redundant closeActiveTool() from MobileCoordButton close path
  (was being called after destroy, not needed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: active mobile toolbar buttons 34x34px (square)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Drastically compress .knowledge/ — keep only unique agent content

Remove 33 wiki files that duplicate docs/pages/ content.
Remove 9 reference/ files derivable from source code.
Keep only 5 files (down from 46):
  - AI-GETTING-STARTED.md (agent setup walkthrough)
  - AI-DEVELOPMENT.md (spec-kit workflow)
  - conventions-and-gotchas.md (naming, code style, common issues)
  - 12-devin-knowledge-notes.md (CI, auth, DB init, security gotchas)
  - README.md (index pointing to docs/pages/ for everything else)

Principle: don't duplicate docs/ — only keep what's uniquely agent-optimized.
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename to knowledge-notes.md, remove Devin branding and fork-specific CI section

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: hide mmgis-map-logo on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore Database Safety Rules for AI Agents section in AGENTS.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: shift compass and map scale 6px to the right (both mobile and desktop)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add back Important Instructions, code pattern templates, and detailed project structure

- Important Instructions in AGENTS.md: MCP tools, hot-reload, Reference Mission
- .knowledge/code-patterns.md: full directory tree with key directory annotations,
  plus copy-paste templates for Express routes, Sequelize models, Tool plugins,
  and WebSocket handlers

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Update project structure trees to reflect current filesystem

Add missing directories: tests/, .knowledge/, .specify/, .github/, views/,
private/, spice/, build/, examples/, scripts/middleware.js.
Both abbreviated (AGENTS.md) and detailed (.knowledge/code-patterns.md) trees
now match the actual repo layout.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.30-20260501 [version bump]

* Add Layers_.js to project structure (key singleton L_)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix project structure: correct API layout, frontend modules, code templates

API/Backend/ uses feature-domain modules (Draw/, Users/, Config/, etc.)
with setup.js + routes/ + models/ per feature — not APIs/ or Databases/.
Frontend essence/ has Components/, Helpers/, LandingPage/, mmgisAPI/,
services/ — not Ancillary/. Basics/ includes all singletons (Globe_,
Formulae_, ToolController_, Viewer_, ComponentController_, Test_).
Code templates updated to match actual patterns (setup.js, module.exports).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove test infrastructure (Test_ module, testModules, DrawTool.test)

- Delete src/essence/Basics/Test_/ directory
- Delete src/essence/Tools/Draw/DrawTool.test.js
- Remove Test_ import and Shift+T keydown handler from essence.js
- Remove tests key from Draw tool config.json
- Remove testModules generation logic from API/updateTools.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.31-20260501 [version bump]

* style: move Cesium link button to top-right and match Leaflet zoom button styling

- Change control container from top-left to top-right positioning
- Update button size from 26px to 30px to match Leaflet zoom controls
- Use CSS variables (--color-a, --color-f, --color-mmgis) instead of hardcoded colors
- Add border-radius and box-shadow matching Leaflet control appearance
- Update hover/inactive states to use themed colors

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: anchor map logo to viewport instead of Leaflet map panel

- Change MapLogo parent from .leaflet-bottom.leaflet-right to #main-container
- Switch CSS position from absolute to fixed for viewport anchoring
- Add explicit bottom-offset positioning in BottomElementPositioner (desktop)
- Add explicit bottom-offset positioning in BottomElementPositioner (mobile)
- Logo stays at viewport right edge regardless of open side panels
- Retains smooth bottom offset transitions when bottom bar appears

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: remove references to deleted test infrastructure (Test_, DrawTool.test)

- Remove Test_/ from project structure in .knowledge/code-patterns.md
- Remove DrawTool.test.js references from specs/006 spec, plan, and tasks
- Remove Draw Tool Testing section from tasks.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.32-20260501 [version bump]

* fix: append logo to document.body to avoid filter containing block

#main-container has a CSS filter property which creates a new containing
block per the CSS spec, causing position:fixed to behave like absolute.
Appending to document.body ensures true viewport-fixed positioning.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: prevent mobile topBarTitleName text wrapping by replacing max-width with white-space: nowrap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.33-20260501 [version bump]

* chore: bump version to 5.0.0 and update changelog

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(ui): move Screenshot/Fullscreen to BottomBar, About to TopBar kebab

TopBar kebab menu now contains only Keyboard Shortcuts, Settings, and About
(About now shows on both desktop and mobile).

BottomBarReact now renders Screenshot, Fullscreen, and Copy Link buttons
(top to bottom) following the same IconButton + Tooltip pattern. The
About button has been removed from BottomBarReact.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(mobile): enforce exclusive panel toggling on mobile in TopBar

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* style: reposition LithoSphere globe controls to match Leaflet/Cesium theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(topbar): hide Viewer/Globe toggles based on configured panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): reorder buttons (Copy Link, Screenshot, Fullscreen) and unify size

Reorder the BottomBarReact buttons top-to-bottom to: Copy Link, Screenshot,
Fullscreen.

Move the 24x24 button sizing from the #topBarLink id selector in mmgis.css
into the .barButton class in BottomBarReact.module.css so all three buttons
share the same compact size as the original Copy Link button. Drop the now
redundant #topBarLink rule.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): increase padding-bottom to 12px and button margin to 3px 0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: rearrange globe controls — compass top-right circular, nav row, vertical column, panels open left

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* chore: bump version to 5.0.2-20260505 [version bump]

* style: anchor observe settings panel right:34px and float nav hover panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(theming): add 5 new themes, --color-shadow variable, and configure ThemePreview

- Add Dark Terra, Dark Nebula, Dark Lunar, Dark Supernova, Light Botanical themes
- Add --color-shadow CSS variable to every theme + :root fallback
- Replace hardcoded rgba shadow colors with var(--color-shadow) in TopBar,
  Toolbar, SeparatedTools, ToolPanel, FloatingElements, Dropdown, Modal,
  and SplitScreens
- Add Custom shadowcolor color picker in tab-ui-config and apply it via Stylize
- Add ThemePreview component (configure/src) wired through Maker.js as
  a new 'themepreview' row type so the configure UI shows a live mini
  mockup of the selected theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* fix(configure/ThemePreview): tighten top spacing and live-preview Custom theme

- Pull the preview up by 12px so the gap below the theme dropdown is tighter.
- Read the Custom color pickers (look.primarycolor / secondarycolor /
  tertiarycolor / accentcolor / shadowcolor / topbarcolor / toolbarcolor /
  mapcolor) from the configuration and overlay them on Dark Default so
  the preview reflects Custom theme edits live, matching Stylize.js's
  runtime behavior.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260505 [version bump]

* feat(themes): add Dark Heliosphere, Dark Monokai, and Light Solarized

- Dark Heliosphere: deep night purple surface with corona-orange accent.
- Dark Monokai: warm graphite surface with lime accent (Monokai-inspired).
- Light Solarized: classic solarized base3/base02 with blue accent.

Mirror added to configure/src/themes/themes.js for the ThemePreview, and
the three names appended to the Color Theme dropdown options.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(coordinates): respect time.initiallyOpen when live deep-link is set

* chore: bump version to 5.0.3-20260505 [version bump]

* refactor(theming): remove Custom theme + per-field color overrides

- Drop the 'Custom' option from the Color Theme dropdown.
- Remove all Custom Color Options (look.primarycolor, .secondarycolor,
  .tertiarycolor, .accentcolor, .bodycolor, .topbarcolor, .toolbarcolor,
  .mapcolor, .hightlightcolor, .shadowcolor) from tab-ui-config.json.
- Strip the matching DOM/CSS-variable override block from Stylize.js;
  Stylize now just applies the selected preset theme (and the page logo).
- Drop the empty bodycolor/topbarcolor/toolbarcolor/mapcolor/shadowcolor
  defaults from API/templates/config_template.js.
- Simplify ThemePreview to render the selected preset directly — no
  Custom branch, no overlay logic.

Preset themes cover all the looks we want and keep the configure surface
much smaller.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(time-ui): round corners on TimeUI shell, action wrappers, mode dropdown

- #timeUI: 10px border-radius on the outer time control bar.
- #mmgisTimeUIActionsLeft / #mmgisTimeUIActionsRight: 10px border-radius
  so the action clusters sit as rounded chips.
- #mmgisTimeUIActionsRight > div (excluding #mmgisTimeUIPresent): 10px
  border-radius on each action button so they match the wrapper.
- #mmgisTimeUIModeDropdown: 40px height + 10px border-radius to align
  with the rest of the bar; clear the dropy default border-color so the
  rounded edge isn't outlined.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260505 [version bump]

* feat(configure): mark light themes as (experimental) in dropdown label

Light themes still have outstanding contrast issues, so flag them in the
Color Theme dropdown without changing the saved value.

- Maker dropdown now accepts options as either a plain string (current
  behavior) or { value, label } so the rendered label can differ from
  the persisted value.
- tab-ui-config switches the six light themes to { value, label } form
  with '(experimental)' appended to the label only. Existing mission
  configs that already saved 'Light Default' etc. continue to match.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix timeUI border radius

* fix(mobile): rescue #timeUI before tool make() destroys it

Clicking Layers -> Time -> Layers -> Time on mobile caused the bottom
panel to render LayersTool content with TimeUI height. The #timeUI DOM
element was destroyed when LayersTool.make() called $('#tools').empty(),
before the async React useEffect in MobileTimeUIToggle could rescue it
to its staging container.

- ToolController_.makeTool: synchronously move #timeUI from #tools back
  to #timeUIMobileStaging (and reset TimeUI store flags) on mobile,
  before invoking the new tool's make().
- MobileTimeUIToggle.handleClick: defensive fallback that re-initializes
  TimeUI if #timeUI no longer exists when the toggle is activated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): move re-initialized #timeUI from staging into #tools

TimeUI.init() on mobile appends the new #timeUI to the hidden
#timeUIMobileStaging container, so the fallback branch must also move
it into #tools — otherwise the user sees an empty tool panel after
the destroyed-element recovery path.

Caught by Devin Review.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): preserve #timeUI when Coordinates tool empties #tools

On mobile, opening or closing the Coordinates tool runs
$('#tools').empty() inside interfaceWithMMWebGIS / separateFromMMWebGIS.
After the previous PR commits, clicking Coordinates -> Time still left
the bottom panel empty because:

- Coordinates.make() empties #tools while #timeUI is in staging (fine
  on its own), but the Coordinates teardown that fires after the user
  switches to the Time toggle (via MobileCoordButton's useEffect on
  activeToolName change) calls Coordinates.destroy() ->
  separateFromMMWebGIS(), which empties #tools wholesale and destroys
  the freshly-placed #timeUI.

Add a rescueMobileTimeUI() helper that moves #timeUI from #tools back
to #timeUIMobileStaging before each tools.empty() call in Coordinates,
mirroring the rescue already done in ToolController_.makeTool().

Coordinates -> Time now correctly shows the TimeUI.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): harden TimeUI fallback recovery (call fina(), de-dupe popovers)

Devin Review correctly flagged that the safety-net path in
MobileTimeUIToggle.handleClick was producing a partially-broken TimeUI
when it fired:

- TimeUI.init() unconditionally appends a new #timeUIPlayPopover_global
  to <body>, so a second init() left two elements with the same id.
- TimeUI.init() alone does not wire up date pickers or per-button click
  handlers — that's TimeUI.fina()'s job. Without fina(), the recovered
  TimeUI rendered visually but Play / Previous / Next / Fit / Follow /
  Present / Expand were all dead.

Before re-initializing, remove the stale #timeUIPlayPopover_global and
#timeUIQuickSelectPopover_global divs to avoid duplicate ids. After the
new #timeUI is moved into #tools, call TimeUI.fina() to populate the
date pickers, attach the button click handlers, build the histogram,
and populate the expanded mobile rows.

Some delegated body/document handlers in attachEvents() will still be
duplicated on this path; that is acceptable for a degraded recovery
that should never run in practice now that the primary rescues in
ToolController_.makeTool() and Coordinates.js cover all known paths.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260505 [version bump]

* fix(mobile): Coordinates teardown only removes its own DOM

The previous Coordinates fix was racing with itself: after the Time
toggle synchronously moved #timeUI into #tools, MobileCoordButton's
useEffect (triggered by the activeToolName change) ran on the next
React tick and called L_.Coordinates.destroy(). That called
separateFromMMWebGIS(), whose rescue moved #timeUI right back into the
hidden staging div before tools.empty() — so the bottom panel ended up
empty even though the time toggle was 'active'.

Make separateFromMMWebGIS selective: only remove the
Coordinates-specific DOM (#coordUIHeader and #CoordinatesDiv) instead
of wiping all of #tools. Any other content already in #tools (e.g.
#timeUI placed there by the Time toggle) is left alone.

interfaceWithMMWebGIS still keeps the rescue + tools.empty() pattern
on the open path so Coordinates always starts from a clean panel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Bump DrawTool Temporal Drawings upward

* chore: bump version to 5.0.6-20260505 [version bump]

* chore: reset version to 5.0.0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test(e2e): fix 9 pre-existing failures (test-only changes)

- mmgis-api.spec.js: add form-fill login under AUTH=local; serialize
  describe to avoid concurrent-login race in the session store
- coordinates.spec.js: TimeUI toggle was moved from the coordinates bar
  to the Settings modal; navigate via topbar kebab menu and assert the
  checkbox is rendered
- widgets.spec.js: target .leaflet-control-zoom-in/-out specifically;
  the bare .leaflet-control-zoom class is also used by the home/reset
  control, so the original assertion was always false
- sites.spec.js: scope panel selector to #toolPanel; both the toolbar
  icon and the panel container share id="SitesTool"

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* Revert "chore: bump version to 5.0.1-20260505 [version bump]"

This reverts commit 4880204c1163be5d1d7fa96d14a0ed018c6f586c.

* fix: prevent filter operator dropdown clipping in Layers panel

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260507 [version bump]

* revert: keep dropy openUp:true for operator dropdowns

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit d67c369ed437e47d658ae051348d377978dc48ed.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 29565ed829a55e9c241a789c9a3901d11cb5ca67.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 50e357604ebe9378564619b34c508b63cfb62c1d.

* chore: bump version to 5.0.1-20260507 [version bump]

* chore: bump version to 5.0.2-20260511 [version bump]

* fix: render Globe panel immediately on first open without window resize

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260511 [version bump]

* feat: add theme borders to panels and gradient backgrounds to splitters

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: bump split shadow gradient opacity to 0.4

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal 3-col grid + smaller leaflet zoom button gap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: prevent hotkey label/value wrapping (ellipsis instead)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal single column, no wrap, no truncation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: hotkeys modal dividers, invert title/subtitle colors, rename title, margin above subtitles

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: move splitter gradient to themed CSS class, restore hover feedback

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* style: hotkeys section titles use --color-h (matches rest of app)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* fix: guard Globe_.init() inside rAF to prevent double instantiation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.6-20260512 [version bump]

* feat(plugins): per-plugin deps, lazy tool loading, validation, shared discovery

Phase 3 — Plugin config validation + override warnings:
- New API/pluginValidation.js with validatePluginConfig() for tool, component,
  and backend manifests. Validates required fields (name, paths), object/string
  shape of paths, dependencies block (npm/python.pip/python.conda), and warns
  on unknown top-level fields.
- updateTools()/updateComponents() now skip invalid plugins and emit override
  warnings (matching what components already logged for tools).

Phase 2 — Shared discoverPlugins() utility:
- New API/pluginDiscovery.js consolidates the duplicated scanning logic from
  updateTools(), updateComponents(), and getBackendSetups(). Supports exact-
  name and substring container patterns, JSON/require/no-op loaders, and skips
  dot/underscore-prefixed dirs.
- updateTools.js and setups.js refactored on top of the shared helper.

Phase 1 — Per-plugin dependency declaration + build-time aggregation:
- Plugin config.json may now declare a 'dependencies' block (npm + python.pip +
  python.conda). validatePluginConfig() also validates this shape.
- New scripts/resolve-plugin-deps.js scans every tool/component/backend plugin
  and writes plugin-package.json, plugin-python-requirements.txt, and
  plugin-conda-deps.txt. Detects version conflicts and fails loudly.
- scripts/build.js calls resolvePluginDeps() before updateTools().
- Dockerfile installs the aggregated plugin npm and pip deps after the root
  npm ci, using --no-save / --no-package-lock / --ignore-scripts so the root
  lockfile is untouched.
- Animation tool migrated: ffmpeg/gifshot/html2canvas now declared in its
  config.json (kept in root package.json for transitional compat).
- Generated artifacts gitignored.

Phase 4 — Lazy loading of tool bundles:
- updateTools() now emits dynamic-import arrow functions in the generated
  src/pre/tools.js with webpackChunkName hints so each tool is split into
  its own chunk (Kinds stays static because it's required synchronously).
- ToolController_ gains ensureToolLoaded(name) and getLoadedTool(name) helpers
  and makeTool is async; init/finalizeTools and the separated-tool auto-open
  flow are updated to handle lazy modules.
- Toolbar.jsx, SeparatedTools.jsx, SitesTool.js, and Layers_.js migrated to
  resolve LayersTool/etc. via the new helpers instead of poking toolModules
  directly.

Tests & docs:
- tests/fixtures/test-plugin-tools/{TestPlugin,InvalidPlugin,OverridePlugin}
  + tests/helpers/plugin-helpers.js with install/uninstall helpers.
- New unit specs: pluginValidation, pluginDiscovery, updateTools,
  resolvePluginDeps, toolLazyLoading (57 tests, all passing).
- CONTRIBUTING.md and docs/pages/Contributing/Contributing.md updated with
  schema, override behaviour, dependency declaration, build-time aggregation,
  conflict detection, and Docker integration.

* chore: bump version to 5.0.7-20260512 [version bump]

* fix: make Globe_.init() idempotent against multi-init

Globe_.init() previously constructed a fresh GlobeRenderer on every call,
which after #71 could happen multiple times for a single toggle (uiStore
setTimeout + TopBar rAF). Each extra construction appends another
.cesium-widget / _lithosphere_scene to #globe and leaves event handlers
wired to dereferenced renderer state, which has been observed to break
LithoSphere globe control buttons on configurations where the globe panel
starts closed at boot.

Add a top-of-init() guard that bails out and calls invalidateSize() when
a renderer already exists. Single small, surgical change; no behavior
change for the !L_.hasGlobe mock-swap path or for first-time construction.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.7-20260512 [version bump]

* test(plugins): generate src/pre/tools.js on demand in toolLazyLoading spec

The Playwright unit-tests CI step runs before `npm run build` so the
gitignored `src/pre/tools.js` artifact does not yet exist on disk.
Add a beforeAll hook that invokes `updateTools()` to regenerate it
when missing, keeping the spec self-contained on both CI and dev
machines that already built locally.

* fix(tools): defensive getTool() + preload flag for cross-referenced tools

Devin Review flagged a behavioural regression introduced by Phase 4:
`ToolController_.getTool(name)` previously always returned a method-
callable object (real module or `{ use(){} }` stub) because every tool
was statically imported. After Phase 4, unresolved lazy loaders are
`() => import(...)` functions, so callers like `Map_.getTool('InfoTool').use(...)`,
`mmgisAPI.getTool('DrawTool').filesOn`, and `LegendTool` calling
`LayersTool.populateCogScale` would crash with TypeError until the
target tool was opened.

Two fixes:

1. **Defensive getTool()**: Returns the legacy fallback stub when the
   tool module is still a lazy-loader function, and fires off
   `ensureToolLoaded(name)` in the background so subsequent calls see
   the resolved module. Prevents all crashes immediately.

2. **`preload: true` config flag**: Tools reached synchronously from
   other code paths (Info, Draw, Layers, Chemistry) now declare
   `"preload": true` in their `config.json`. `ToolController_.init()`
   calls `preloadEagerTools()` which fires `ensureToolLoaded` for
   every such tool right after toolbar setup — the chunks download
   in parallel with the rest of the page becoming interactive, so by
   the time a user clicks a feature the InfoTool module is already
   resolved.

`validatePluginConfig` now accepts `preload` as a known tool field;
CONTRIBUTING.md and docs/pages/Contributing/Contributing.md updated to
document when to set it. Added a unit test covering the defensive
getTool behaviour and the `preload` propagation through
`toolConfigs`.

* chore: bump version to 5.0.8-20260512 [version bump]

* revert(plugins): remove Phase 4 lazy tool loading and preload mechanism

Phase 4 lazy emission caused cross-tool consumers (Map_ feature-click,
mmgisAPI, LegendTool) to receive raw '() => import(...)' arrows from
ToolController_.getTool(), breaking InfoTool open. Reverting to the
pre-Phase-4 behavior of static tool imports.

- API/updateTools.js: generated src/pre/tools.js now emits
  'import FooTool from ...' for every tool (Kinds stays static too).
- ToolController_.js: getTool/makeTool back to sync; ensureToolLoaded,
  getLoadedTool, preloadEagerTools deleted; separated-tool auto-open
  flow simplified to direct sync calls.
- Toolbar.jsx, SeparatedTools.jsx, Layers_.js: revert async/lazy
  patterns to sync ToolController_.toolModules[name] access.
- API/pluginValidation.js: drop 'preload' from KNOWN_FIELDS.
- src/essence/Tools/{Info,Draw,Layers,Chemistry}/config.json: drop
  'preload: true'.
- CONTRIBUTING.md + docs: remove preload documentation.
- tests/unit/toolLazyLoading.spec.js: rewrite to verify static
  imports instead of lazy loaders.

Also: log standard backends at startup (parity with plugin backends
and with tools/components), so all backends now produce
'info  Loaded backend: <name> from <container>' at boot.

Phases 1-3 (per-plugin dependency aggregation, shared discoverPlugins,
config validation + override warnings) are unaffected.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(logger): new 'loaded' level (purple bg) for tool/component/backend startup

Previously the 'Loaded tool/component/backend: X from Y' lines used
the generic blue 'info' tag. They now use a dedicated 'loaded' level
rendered with a purple (#a855f7) background, so plugin discovery
output is visually distinct from other info messages.

- API/logger.js: add 'loaded' case to the dev-mode switch (white text
  on purple bg) and suppress the redundant 'Caller:' echo for it
  (matches how 'info' and 'success' are handled).
- API/updateTools.js: registerPlugin now logs at level 'loaded'.
  Drops the redundant 'Loaded ' prefix since the level tag now reads
  'loaded'.
- API/setups.js: standard and plugin backend startup logs use the
  new level, same drop of the 'Loaded ' prefix.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(logs): 'Plugging in Tools/Components/Backends...' headings

Rename the cyan banner messages in scripts/build.js and scripts/server.js
from 'Updating Tools...' / 'Updating Components...' to 'Plugging in
Tools...' / 'Plugging in Components...' so the headings match the
plugin terminology used everywhere else (plugin-package.json,
discoverPlugins, etc.).

Also add a matching 'Plugging in Backends...' banner before
setups.getBackendSetups() in scripts/server.js so backends get an
equivalent title block to tools and components.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(logs): cyan banner lines lead with a blank line instead of trailing one

Move the \n from the end to the beginning of every cyan banner in
scripts/build.js and scripts/server.js (Resolving Plugin Dependencies,
Plugging in Tools/Components/Backends, Validating Environment
Variables, Starting websocket, Starting the development server) so
that the blank line visually separates each section above its title
rather than below it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(plugins): postinstall hook auto-installs plugin npm deps

Plain `npm install` (or `npm ci`) on a fresh clone now resolves and
installs every plugin's declared npm dependencies automatically, so
new developers don't need to remember a second command.

- scripts/install-plugin-deps.js (new): reads plugin-package.json,
  filters out deps already declared in root package.json with the
  same version specifier (no-op for the Animation transitional case),
  installs the remainder with `npm install --no-save --no-package-lock
  --ignore-scripts <pkg@ver> ...`. `--no-package-lock` keeps the
  root lockfile clean; `--ignore-scripts` prevents the inner install
  from re-entering postinstall and matches the Dockerfile.
- package.json: postinstall guards against the Dockerfile's
  package.json-only layer (`scripts/` not copied yet) by checking
  for the two script files via `node -e` before invoking them.
  Adds a `plugins:install` npm script for on-demand runs.
- CONTRIBUTING.md + docs/pages/Contributing/Contributing.md: replace
  the manual-install paragraph with a note about the postinstall
  hook and the filtering behavior.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs(plugins): manual Python install step for plugin pip/conda deps

Document that the npm postinstall hook only handles plugin npm deps —
plugin pip/conda deps must be installed manually after creating the
Python environment, since there's no portable way to detect which
interpreter or environment to target from a Node script.

- CONTRIBUTING.md: added a 'For local development ... Python' block
  with the explicit `node scripts/resolve-plugin-deps.js` +
  `micromamba run -n mmgis pip install -r plugin-python-requirements.txt`
  + optional conda install commands.
- docs/pages/Contributing/Contributing.md: matching short blurb in
  the user-facing docs.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs(install): plugin Python install step in Installation.md

Add a numbered step in docs/pages/Setup/Installation/Installation.md's
Setup sequence (right after `micromamba activate mmgis`) with the
`pip install -r plugin-python-requirements.txt` and optional
`micromamba install --file plugin-conda-deps.txt` commands, so
non-Docker installs have the step in their main flow rather than
buried in CONTRIBUTING.md.

Also adds a pointer from the Python Environment section earlier in
the same file (after the env-create + activ…
…982)

* Guard Legend empty state message to only show when panel is active

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix TimeUI dropdown covered by toolPanel: remove splitscreens stacking context

#splitscreens had z-index:1 which created a stacking context, confining
its children (including bottomFloatingBar at z-index:1500) to that context.
Since ToolPanel (z-index:1400) was a sibling outside splitscreens, it
painted above all splitscreens children regardless of their internal
z-index values.

Fix: change #splitscreens z-index from 1 to auto so it no longer creates
a stacking context. Now bottomFloatingBar (1500) participates in the
same stacking context as ToolPanel (1400), and 1500 > 1400 means the
TimeUI dropdown correctly paints above the tool panel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Migrate ~69 CursorInfo toast-like calls to proper Toast component

- Replace CursorInfo.update() toast-like calls with Toast.info/success/warning/error
- Preserve message colors: blue→info, green→success, yellow→warning, red→error
- Keep 12 legitimate cursor-following CursorInfo calls unchanged
- Files migrated: DrawTool.js, DrawTool_Files.js, DrawTool_FileModal.js,
  DrawTool_Templater.js, DrawTool_SetOperations.js, DrawTool_Drawing.js,
  DrawTool_Editing.js, DrawTool_Shapes.js, LayersTool.js, ShadeTool.js,
  chemistrychart.js
- Fix Devin Review: Change misleading 'Bad token' to 'Login failed' in users.js
- Normalize line endings (CRLF→LF) in affected files

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Toast.js missing from git, CoordinatesDiv z-index, Legend duplicate ID, topBar padding

- Add Toast.js to version control (was untracked, causing webpack module error)
- Bump CoordinatesDiv z-index from 20 to 1001 (was hidden behind splitscreens
  children after z-index:auto change)
- Fix Legend duplicate ID: separated tool icon was #LegendTool, same as content
  container div, causing empty message to appear in button instead of panel
- Add hasStatus class to #topBarMain when statusIndicator is active, setting
  #topBarTitleName padding-left to 0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix Legend empty message: scope selector to content container via targetId

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Move Toast.js to design-system, fix --color-a3 text contrast, update AGENTS.md

- Move Toast.js from UserInterface_/components/Toast/ to design-system/components/Toast/
  (generic component belongs in design-system, not MMGIS-specific UserInterface_)
- Update all 11 Toast import paths to new location
- Bump --color-a3 in 5 dark themes to pass WCAG AA 4.5:1 contrast for text:
  Dark Default #747c81→#81888d, Dark Blue #64748b→#738399,
  Dark Warm #8b7a5e→#908064, Dark Mars #8a6a60→#98796f,
  Dark Midnight #606088→#7a7a9e
- Update AGENTS.md: document design-system/ vs UserInterface_/ distinction
  in project structure and Key Directories

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix topBarTitleName padding override: increase specificity and add !important

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix IdentifierTool deactivation: update icon ID reference in separateFromMMWebGIS

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Hide toolPanelDrag when no tool is open

- Add setToolPanelDragVisible(false) to closeActiveTool() (was only in makeTool toggle-off path)
- Also guard drag handle display on isOpen (toolPanelWidth > 0) as safety net

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add hover effect to MMGIS logo (opacity + brightness transition)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix MMGIS logo hover: keep full opacity, use subtle background highlight instead

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Selective tile fade: fade on pan/zoom, instant on refresh/reload

- Remove blanket _fadeAnimated toggle from toggleTimeUI (was killing fade
  for all tiles while TimeUI was open)
- Monkey-patch GridLayer.redraw, TileLayer.setUrl, and GridLayer._tileReady
  to suppress fade via a transient _suppressTileFade map flag
- Set _suppressTileFade in reloadTimeLayers for time-driven reloads
- Flag auto-clears after 300ms so pan/zoom tiles still get the nice fade
- Install pbf dependency (required by CesiumMVTLayer from #942 merge)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Per-layer fade control: time-enabled + shade/viewshed layers never fade

- Replace transient map-level _suppressTileFade with per-layer _noFade flag
- Patch GridLayer._tileReady to check _noFade on the layer instance
- Set _noFade on time-enabled tile layers and data/GL layers at creation
- Set _noFade on Shade and Viewshed tool GL layers
- Non-time-enabled base imagery tiles still fade normally on pan/zoom

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile UI improvements — move hamburger to right menu, show panel toggles, isMobile-driven toolbar layout, desktop-matching scalebar/compass

- Remove left hamburger menu (#topBarMenu), move BottomBar items into
  top-right kebab dropdown menu for both mobile and desktop
- Show panel toggles (Viewer/Map/Globe) and account/login UI in mobile
  topbar's #topBarRight
- Move toolbar horizontal layout CSS from @media breakpoints to
  UserInterfaceMobile_.css (loaded only when isMobile flag is true)
- Remove #mapTopBar @media rule from mmgis.css, add to mobile CSS
- Remove mobile-only simplified scalebar rendering; use full desktop
  scalebar with both large and small axes on all viewports
- Remove display:none on .leaflet-control-scalefactor in mobile CSS
- Remove #loginDiv display:none from mobile CSS (React overlay handles it)
- Simplify BottomBarReact container styles (no more absolute positioning)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260430 [version bump]

* fix: mobile toolbar 40px height, bottomFloatingBar flush, timeUI toggle, scalebar position, hide hotkeys

- Toolbar height 40px, toolButton width 40px, no border-bottom
- toolcontroller_incdiv: no padding-bottom, overflow-y hidden
- bottomFloatingBar: no border-radius, left/right/bottom = 0
- Add MobileTimeUIToggle button on far right of toolbar
- Hide Keyboard Shortcuts from kebab menu on mobile
- Fix scalebar positioning (remove top:48px override in UserInterfaceBridge)
- Set mobileTopSize/topSize to 40 (splitscreens top = 40px, not 50px)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile topBar padding-left 34px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: update MobileCoordButton topBar paddingLeft from 80px to 34px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: MobileTimeUIToggle — inline toggle logic, float right, hide from settings on mobile

- Replace broken Coordinates.toggleTimeUI() call with direct jQuery/store toggle
- Float time button right in toolbar
- Hide Time UI toggle from settings modal on mobile (toolbar has it)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: push scalebar/compass/scale up 40px on mobile, keep #timeUI in DOM

- BottomElementPositioner: position mapToolBar, leaflet-bottom-left/right
  40px above bottom on mobile (above toolbar)
- Stop removing #timeUI from DOM on mobile so MobileTimeUIToggle works

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — only show endtime, always expanded

- Hide #mmgisTimeUIStartWrapper and StartWrapperFake on mobile via CSS
- Force expanded state (addClass expanded + show) when toggling TimeUI on
- CSS ensures #timeUI.active always shows expanded content on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI opens in tool panel with header, end time, expanded rows

- MobileTimeUIToggle now opens/closes the tool panel via ToolController_
- Closes any active tool before showing TimeUI
- Forces expanded state when opening
- CSS hides start time inputs, positions expanded content properly
- Overrides absolute positioning of expanded content for tool panel flow

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: rewrite separated tools system from jQuery to React components

- Add separatedToolsList/activeSeparatedTools state to Zustand uiStore
- Rewrite SeparatedTools.jsx with glassmorphism panels, CSS Module styling
- Replace SepToolsContainer (setInterval hack) with SepToolButton/SepToolsSection
- Remove ~170 lines of jQuery DOM construction from ToolController_.js
- Fix hardcoded rgba(26,26,27,0.88) to theme-aware var(--color-a-rgb)
- Remove separated tool entries from themeApplier.js
- Remove separated tool overrides from FloatingElements.css
- Move Legend CSS overrides from Toolbar.module.css to SeparatedTools.module.css
- Remove jQuery active-state manipulation from IdentifierTool.js
- Add store sync in Map_.js displayOnStart logic
- Preserve all DOM IDs for backward compatibility (mmgisAPI, tool make())

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260501 [version bump]

* fix: TimeUI mobile checks — use Zustand store instead of L_.UserInterface_

L_.UserInterface_ is null when TimeUI.init() runs (TimeControl.init is called
before L_.link sets UserInterface_). All 16 isMobile checks now read from
useUIStore.getState().isMobile which is set at startup.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: move displayOnStart logic from Map_.js to ToolController_.finalizeTools()

- Map_ no longer references specific tools (LegendTool)
- displayOnStart is now handled generically for all separated tools
- Added DOM element polling (tryMake) to handle React render timing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* revert: remove all TimeUI-related mobile changes

Reverts TimeUI.js and BottomBar.js to development base.
Restores #timeUI DOM removal in UserInterfaceBridge.fina().
Removes MobileTimeUIToggle component from Toolbar.jsx.
Removes TimeUI mobile CSS overrides from UserInterfaceMobile_.css.
Non-TimeUI refinements (toolbar height, scalebar positioning, etc.) preserved.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* simplify: remove DOM polling, use simple setTimeout(0) for auto-open

LegendTool handles its own content lifecycle via subscribeOnLayerToggle.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: mobile TimeUI — fix isMobile detection, staging container, toolbar toggle

- TimeUI.js: import useUIStore and replace all 16 L_.UserInterface_?.isMobile
  checks with useUIStore.getState().isMobile (L_.UserInterface_ is null when
  TimeUI.init() runs, so mobile conditionals were dead code)
- TimeUI.js: stage mobile #timeUI in hidden #timeUIMobileStaging instead of
  placing directly in #tools (which gets cleared by other tools)
- UserInterfaceBridge.js: stop removing #timeUI from DOM on mobile
- Toolbar.jsx: add MobileTimeUIToggle that moves #timeUI between staging and
  #tools, opens/closes tool panel via ToolController_
- BottomBar.js: hide TimeUI toggle from settings modal on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: rescue #timeUI back to staging when another tool opens

Subscribe to activeToolName changes — when a tool becomes active while
TimeUI is showing, move #timeUI back to #timeUIMobileStaging before
the new tool's make() clears #tools.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove separatedTool/justification config toggles, fix review issues

- Remove separatedTool checkbox and justification dropdown from Legend
  and Identifier config.json (these are always separated, not configurable)
- Remove justification property/code from LegendTool.js, IdentifierTool.js
- Simplify Globe_.js separated tool count (no justification filter)
- Remove justification from Reference-Mission config blueprint
- Update LegendTool help docs and Legend.md documentation
- Add --color-a-rgb fallback (29,31,32) in SeparatedTools.module.css
- Add display:none !important to .panelIdentifier to prevent 12px gap
- Update e2e test comment

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: circular import in TimeUI.js, toolbar/bottomFloatingBar position sync

- TimeUI.js: replace top-level useUIStore import with lazy _getUIStore()
  accessor to avoid 'Cannot access useUIStore before initialization'
  circular import error at _remakeTimeSlider
- SplitScreens.jsx: skip #timeUI reparenting observer on mobile (mobile
  uses MobileTimeUIToggle to manage #timeUI placement in #tools)
- BottomElementPositioner.jsx: unify mobile transition to 0.3s (matches
  toolsWrapper and toolbar), guard pxIsTools against undefined
- Toolbar.jsx: align toolbar transition to 0.3s ease-out

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* LegendTool fix empty message

* chore: remove separated tools offset logic from Globe_.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: skip _makeHistogram on mobile (no timeline slider, timestamps unset)

_makeHistogram renders inside the timeline slider which doesn't exist
on mobile. Without it, _timelineStartTimestamp is NaN, causing
'Invalid time value' RangeError at toISOString().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — populate expanded rows, fix Invalid date, fix panel height

- TimeUI.js attachEvents: use _initialStart/_initialEnd on mobile (same
  as desktop) instead of L_.TimeControl_ which isn't set yet at init time.
  Fixes 'Invalid date' in start/end time inputs.
- TimeUI.js fina: set expanded=true on mobile and call _populateExpandedRows()
  so year/month/day/hour rows actually render. Removed position:absolute and
  pointer-events:none overrides.
- Toolbar.jsx: set tool panel height to 217px (TimeUI.height) instead of
  45% viewport — matches actual TimeUI content height.
- UserInterfaceMobile_.css: expanded content flows naturally (position:relative),
  hide start time inputs, allow overflow scroll, flex-wrap topbar.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI justify-content center, restore toolbar border-bottom

- Add justify-content: center to #mmgisTimeUIMain on mobile
- Remove border-bottom: none override so toolbar keeps its default border

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI overflow hidden, scalebar/compass fixed at 40px offset

- #timeUI overflow-y: hidden (was auto, causing 2px scroll)
- Scalebar/compass/map controls stay at fixed 40px offset (above toolbar)
  regardless of tool panel state — no longer shift up by pxIsTools

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Implement multi-tier knowledge architecture

- Restructure AGENTS.md from 745 lines to 106 lines (Tier 1: essential context)
- Create knowledge/ directory with 30+ wiki-style documentation files (Tier 2: deep knowledge)
- Create knowledge/reference/ with 8 detailed reference files (Tier 3: lookup material)
- Move AI-GETTING-STARTED.md and AI-DEVELOPMENT.md to knowledge/
- Update all file references in .specify/templates and blueprints
- Create knowledge/README.md as the full knowledge base index
- Create knowledge/reference/README.md as reference material index

Three-tier knowledge discovery system:
  Tier 1: AGENTS.md (~106 lines) - scannable in <2 minutes
  Tier 2: knowledge/*.md - deep knowledge on architecture, tools, APIs, DB, infra
  Tier 3: knowledge/reference/*.md - coding conventions, API reference, troubleshooting

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: mobile toolbar active button style matches desktop, fix icon alignment

- All mobile toolbar buttons (ToolButton, MobileCoordButton, MobileTimeUIToggle)
  now use display:flex with align-items/justify-content center for proper
  vertical icon centering
- MobileCoordButton: changed 'active' class to 'toolButtonActive' to match
  the global CSS active style (color-mmgis + color-i background)
- Removed inline color overrides so CSS .toolButtonActive takes effect

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add Devin knowledge notes from past MMGIS sessions

Include curated lessons learned from past Devin sessions:
- CI/CD: ignore build-arm64/amd64 failures, focus on required checks
- Child sessions: no separate PRs when consolidating
- ENV triple-update rule (.env, sample.env, ENVs.md)
- Error handling: use logger with infrastructure_error for fatal startup errors
- Path traversal security: stay within /Missions, handle subpath serving
- Database initialization architecture and migration patterns
- API authentication behavior across AUTH modes
- Auto-generated MMGIS concept index

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar active button style, icon alignment, tool deactivation

- Active toolbar buttons get desktop-matching margin (1px 0) and
  border-radius (8px) via .toolButton.toolButtonActive CSS rule
- Removed line-height: 40px from .toolButton (flex centering handles
  vertical alignment, line-height was pushing icons up)
- MobileCoordButton now watches activeToolName store and deactivates
  when another tool opens (fixes coords staying active)
- MobileTimeUIToggle sets activeToolName='MobileTimeUI' when opening
  so coords/other buttons can detect it and deactivate
- MobileTimeUIToggle clears activeToolName when closing
- Both custom buttons skip self-deactivation via name check

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix cross-references: convert backtick refs to markdown links, add Devin knowledge notes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon height 40px, button margins for active padding

- #toolbar .toolButton i: height 40px fixes icon vertical alignment
- #toolbar .toolButton: margin 0 2px gives spacing between buttons
- #toolbar .toolButton.toolButtonActive: margin 1px 2px so active
  background has visual padding around the icon

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename knowledge/ to .knowledge/ for consistency with .specify/ convention

Dot-prefix signals agent infrastructure (not source code), consistent with
.specify/, .github/, .vscode/ conventions. All cross-references updated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon line-height 40px, active button padding via height

- Coord and TimeUI button <i> icons get line-height: 40px
- Active buttons: height 34px (vs 40px toolbar) creates visual padding
  around the active background, centered by flex align-items
- Buttons get margin: 0 1px for horizontal spacing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix broken cross-reference: 06.2 -> 06.1-configure-rest-api.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: close active tool + cancel deferred cleanup in MobileCoordButton/TimeUI

- MobileCoordButton: call closeActiveTool() before opening, destroy
  _pendingCloseTool if set, increment _closeSeq to cancel deferred
  tools.innerHTML clear
- MobileTimeUIToggle: same _pendingCloseTool + _closeSeq fix after
  closeActiveTool() to prevent 420ms deferred cleanup from wiping
  #timeUI after it's placed in #tools
- Removed redundant closeActiveTool() from MobileCoordButton close path
  (was being called after destroy, not needed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: active mobile toolbar buttons 34x34px (square)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Drastically compress .knowledge/ — keep only unique agent content

Remove 33 wiki files that duplicate docs/pages/ content.
Remove 9 reference/ files derivable from source code.
Keep only 5 files (down from 46):
  - AI-GETTING-STARTED.md (agent setup walkthrough)
  - AI-DEVELOPMENT.md (spec-kit workflow)
  - conventions-and-gotchas.md (naming, code style, common issues)
  - 12-devin-knowledge-notes.md (CI, auth, DB init, security gotchas)
  - README.md (index pointing to docs/pages/ for everything else)

Principle: don't duplicate docs/ — only keep what's uniquely agent-optimized.
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename to knowledge-notes.md, remove Devin branding and fork-specific CI section

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: hide mmgis-map-logo on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore Database Safety Rules for AI Agents section in AGENTS.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: shift compass and map scale 6px to the right (both mobile and desktop)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add back Important Instructions, code pattern templates, and detailed project structure

- Important Instructions in AGENTS.md: MCP tools, hot-reload, Reference Mission
- .knowledge/code-patterns.md: full directory tree with key directory annotations,
  plus copy-paste templates for Express routes, Sequelize models, Tool plugins,
  and WebSocket handlers

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Update project structure trees to reflect current filesystem

Add missing directories: tests/, .knowledge/, .specify/, .github/, views/,
private/, spice/, build/, examples/, scripts/middleware.js.
Both abbreviated (AGENTS.md) and detailed (.knowledge/code-patterns.md) trees
now match the actual repo layout.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.30-20260501 [version bump]

* Add Layers_.js to project structure (key singleton L_)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix project structure: correct API layout, frontend modules, code templates

API/Backend/ uses feature-domain modules (Draw/, Users/, Config/, etc.)
with setup.js + routes/ + models/ per feature — not APIs/ or Databases/.
Frontend essence/ has Components/, Helpers/, LandingPage/, mmgisAPI/,
services/ — not Ancillary/. Basics/ includes all singletons (Globe_,
Formulae_, ToolController_, Viewer_, ComponentController_, Test_).
Code templates updated to match actual patterns (setup.js, module.exports).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove test infrastructure (Test_ module, testModules, DrawTool.test)

- Delete src/essence/Basics/Test_/ directory
- Delete src/essence/Tools/Draw/DrawTool.test.js
- Remove Test_ import and Shift+T keydown handler from essence.js
- Remove tests key from Draw tool config.json
- Remove testModules generation logic from API/updateTools.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.31-20260501 [version bump]

* style: move Cesium link button to top-right and match Leaflet zoom button styling

- Change control container from top-left to top-right positioning
- Update button size from 26px to 30px to match Leaflet zoom controls
- Use CSS variables (--color-a, --color-f, --color-mmgis) instead of hardcoded colors
- Add border-radius and box-shadow matching Leaflet control appearance
- Update hover/inactive states to use themed colors

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: anchor map logo to viewport instead of Leaflet map panel

- Change MapLogo parent from .leaflet-bottom.leaflet-right to #main-container
- Switch CSS position from absolute to fixed for viewport anchoring
- Add explicit bottom-offset positioning in BottomElementPositioner (desktop)
- Add explicit bottom-offset positioning in BottomElementPositioner (mobile)
- Logo stays at viewport right edge regardless of open side panels
- Retains smooth bottom offset transitions when bottom bar appears

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: remove references to deleted test infrastructure (Test_, DrawTool.test)

- Remove Test_/ from project structure in .knowledge/code-patterns.md
- Remove DrawTool.test.js references from specs/006 spec, plan, and tasks
- Remove Draw Tool Testing section from tasks.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.32-20260501 [version bump]

* fix: append logo to document.body to avoid filter containing block

#main-container has a CSS filter property which creates a new containing
block per the CSS spec, causing position:fixed to behave like absolute.
Appending to document.body ensures true viewport-fixed positioning.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: prevent mobile topBarTitleName text wrapping by replacing max-width with white-space: nowrap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.33-20260501 [version bump]

* chore: bump version to 5.0.0 and update changelog

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(ui): move Screenshot/Fullscreen to BottomBar, About to TopBar kebab

TopBar kebab menu now contains only Keyboard Shortcuts, Settings, and About
(About now shows on both desktop and mobile).

BottomBarReact now renders Screenshot, Fullscreen, and Copy Link buttons
(top to bottom) following the same IconButton + Tooltip pattern. The
About button has been removed from BottomBarReact.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(mobile): enforce exclusive panel toggling on mobile in TopBar

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* style: reposition LithoSphere globe controls to match Leaflet/Cesium theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(topbar): hide Viewer/Globe toggles based on configured panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): reorder buttons (Copy Link, Screenshot, Fullscreen) and unify size

Reorder the BottomBarReact buttons top-to-bottom to: Copy Link, Screenshot,
Fullscreen.

Move the 24x24 button sizing from the #topBarLink id selector in mmgis.css
into the .barButton class in BottomBarReact.module.css so all three buttons
share the same compact size as the original Copy Link button. Drop the now
redundant #topBarLink rule.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): increase padding-bottom to 12px and button margin to 3px 0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: rearrange globe controls — compass top-right circular, nav row, vertical column, panels open left

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* chore: bump version to 5.0.2-20260505 [version bump]

* style: anchor observe settings panel right:34px and float nav hover panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(theming): add 5 new themes, --color-shadow variable, and configure ThemePreview

- Add Dark Terra, Dark Nebula, Dark Lunar, Dark Supernova, Light Botanical themes
- Add --color-shadow CSS variable to every theme + :root fallback
- Replace hardcoded rgba shadow colors with var(--color-shadow) in TopBar,
  Toolbar, SeparatedTools, ToolPanel, FloatingElements, Dropdown, Modal,
  and SplitScreens
- Add Custom shadowcolor color picker in tab-ui-config and apply it via Stylize
- Add ThemePreview component (configure/src) wired through Maker.js as
  a new 'themepreview' row type so the configure UI shows a live mini
  mockup of the selected theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* fix(configure/ThemePreview): tighten top spacing and live-preview Custom theme

- Pull the preview up by 12px so the gap below the theme dropdown is tighter.
- Read the Custom color pickers (look.primarycolor / secondarycolor /
  tertiarycolor / accentcolor / shadowcolor / topbarcolor / toolbarcolor /
  mapcolor) from the configuration and overlay them on Dark Default so
  the preview reflects Custom theme edits live, matching Stylize.js's
  runtime behavior.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260505 [version bump]

* feat(themes): add Dark Heliosphere, Dark Monokai, and Light Solarized

- Dark Heliosphere: deep night purple surface with corona-orange accent.
- Dark Monokai: warm graphite surface with lime accent (Monokai-inspired).
- Light Solarized: classic solarized base3/base02 with blue accent.

Mirror added to configure/src/themes/themes.js for the ThemePreview, and
the three names appended to the Color Theme dropdown options.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(coordinates): respect time.initiallyOpen when live deep-link is set

* chore: bump version to 5.0.3-20260505 [version bump]

* refactor(theming): remove Custom theme + per-field color overrides

- Drop the 'Custom' option from the Color Theme dropdown.
- Remove all Custom Color Options (look.primarycolor, .secondarycolor,
  .tertiarycolor, .accentcolor, .bodycolor, .topbarcolor, .toolbarcolor,
  .mapcolor, .hightlightcolor, .shadowcolor) from tab-ui-config.json.
- Strip the matching DOM/CSS-variable override block from Stylize.js;
  Stylize now just applies the selected preset theme (and the page logo).
- Drop the empty bodycolor/topbarcolor/toolbarcolor/mapcolor/shadowcolor
  defaults from API/templates/config_template.js.
- Simplify ThemePreview to render the selected preset directly — no
  Custom branch, no overlay logic.

Preset themes cover all the looks we want and keep the configure surface
much smaller.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(time-ui): round corners on TimeUI shell, action wrappers, mode dropdown

- #timeUI: 10px border-radius on the outer time control bar.
- #mmgisTimeUIActionsLeft / #mmgisTimeUIActionsRight: 10px border-radius
  so the action clusters sit as rounded chips.
- #mmgisTimeUIActionsRight > div (excluding #mmgisTimeUIPresent): 10px
  border-radius on each action button so they match the wrapper.
- #mmgisTimeUIModeDropdown: 40px height + 10px border-radius to align
  with the rest of the bar; clear the dropy default border-color so the
  rounded edge isn't outlined.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260505 [version bump]

* feat(configure): mark light themes as (experimental) in dropdown label

Light themes still have outstanding contrast issues, so flag them in the
Color Theme dropdown without changing the saved value.

- Maker dropdown now accepts options as either a plain string (current
  behavior) or { value, label } so the rendered label can differ from
  the persisted value.
- tab-ui-config switches the six light themes to { value, label } form
  with '(experimental)' appended to the label only. Existing mission
  configs that already saved 'Light Default' etc. continue to match.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix timeUI border radius

* fix(mobile): rescue #timeUI before tool make() destroys it

Clicking Layers -> Time -> Layers -> Time on mobile caused the bottom
panel to render LayersTool content with TimeUI height. The #timeUI DOM
element was destroyed when LayersTool.make() called $('#tools').empty(),
before the async React useEffect in MobileTimeUIToggle could rescue it
to its staging container.

- ToolController_.makeTool: synchronously move #timeUI from #tools back
  to #timeUIMobileStaging (and reset TimeUI store flags) on mobile,
  before invoking the new tool's make().
- MobileTimeUIToggle.handleClick: defensive fallback that re-initializes
  TimeUI if #timeUI no longer exists when the toggle is activated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): move re-initialized #timeUI from staging into #tools

TimeUI.init() on mobile appends the new #timeUI to the hidden
#timeUIMobileStaging container, so the fallback branch must also move
it into #tools — otherwise the user sees an empty tool panel after
the destroyed-element recovery path.

Caught by Devin Review.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): preserve #timeUI when Coordinates tool empties #tools

On mobile, opening or closing the Coordinates tool runs
$('#tools').empty() inside interfaceWithMMWebGIS / separateFromMMWebGIS.
After the previous PR commits, clicking Coordinates -> Time still left
the bottom panel empty because:

- Coordinates.make() empties #tools while #timeUI is in staging (fine
  on its own), but the Coordinates teardown that fires after the user
  switches to the Time toggle (via MobileCoordButton's useEffect on
  activeToolName change) calls Coordinates.destroy() ->
  separateFromMMWebGIS(), which empties #tools wholesale and destroys
  the freshly-placed #timeUI.

Add a rescueMobileTimeUI() helper that moves #timeUI from #tools back
to #timeUIMobileStaging before each tools.empty() call in Coordinates,
mirroring the rescue already done in ToolController_.makeTool().

Coordinates -> Time now correctly shows the TimeUI.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): harden TimeUI fallback recovery (call fina(), de-dupe popovers)

Devin Review correctly flagged that the safety-net path in
MobileTimeUIToggle.handleClick was producing a partially-broken TimeUI
when it fired:

- TimeUI.init() unconditionally appends a new #timeUIPlayPopover_global
  to <body>, so a second init() left two elements with the same id.
- TimeUI.init() alone does not wire up date pickers or per-button click
  handlers — that's TimeUI.fina()'s job. Without fina(), the recovered
  TimeUI rendered visually but Play / Previous / Next / Fit / Follow /
  Present / Expand were all dead.

Before re-initializing, remove the stale #timeUIPlayPopover_global and
#timeUIQuickSelectPopover_global divs to avoid duplicate ids. After the
new #timeUI is moved into #tools, call TimeUI.fina() to populate the
date pickers, attach the button click handlers, build the histogram,
and populate the expanded mobile rows.

Some delegated body/document handlers in attachEvents() will still be
duplicated on this path; that is acceptable for a degraded recovery
that should never run in practice now that the primary rescues in
ToolController_.makeTool() and Coordinates.js cover all known paths.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260505 [version bump]

* fix(mobile): Coordinates teardown only removes its own DOM

The previous Coordinates fix was racing with itself: after the Time
toggle synchronously moved #timeUI into #tools, MobileCoordButton's
useEffect (triggered by the activeToolName change) ran on the next
React tick and called L_.Coordinates.destroy(). That called
separateFromMMWebGIS(), whose rescue moved #timeUI right back into the
hidden staging div before tools.empty() — so the bottom panel ended up
empty even though the time toggle was 'active'.

Make separateFromMMWebGIS selective: only remove the
Coordinates-specific DOM (#coordUIHeader and #CoordinatesDiv) instead
of wiping all of #tools. Any other content already in #tools (e.g.
#timeUI placed there by the Time toggle) is left alone.

interfaceWithMMWebGIS still keeps the rescue + tools.empty() pattern
on the open path so Coordinates always starts from a clean panel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Bump DrawTool Temporal Drawings upward

* chore: bump version to 5.0.6-20260505 [version bump]

* chore: reset version to 5.0.0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test(e2e): fix 9 pre-existing failures (test-only changes)

- mmgis-api.spec.js: add form-fill login under AUTH=local; serialize
  describe to avoid concurrent-login race in the session store
- coordinates.spec.js: TimeUI toggle was moved from the coordinates bar
  to the Settings modal; navigate via topbar kebab menu and assert the
  checkbox is rendered
- widgets.spec.js: target .leaflet-control-zoom-in/-out specifically;
  the bare .leaflet-control-zoom class is also used by the home/reset
  control, so the original assertion was always false
- sites.spec.js: scope panel selector to #toolPanel; both the toolbar
  icon and the panel container share id="SitesTool"

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* Revert "chore: bump version to 5.0.1-20260505 [version bump]"

This reverts commit 4880204c1163be5d1d7fa96d14a0ed018c6f586c.

* fix: prevent filter operator dropdown clipping in Layers panel

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260507 [version bump]

* revert: keep dropy openUp:true for operator dropdowns

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit d67c369ed437e47d658ae051348d377978dc48ed.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 29565ed829a55e9c241a789c9a3901d11cb5ca67.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 50e357604ebe9378564619b34c508b63cfb62c1d.

* chore: bump version to 5.0.1-20260507 [version bump]

* chore: bump version to 5.0.2-20260511 [version bump]

* fix: render Globe panel immediately on first open without window resize

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260511 [version bump]

* feat: add theme borders to panels and gradient backgrounds to splitters

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: bump split shadow gradient opacity to 0.4

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal 3-col grid + smaller leaflet zoom button gap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: prevent hotkey label/value wrapping (ellipsis instead)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal single column, no wrap, no truncation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: hotkeys modal dividers, invert title/subtitle colors, rename title, margin above subtitles

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: move splitter gradient to themed CSS class, restore hover feedback

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* style: hotkeys section titles use --color-h (matches rest of app)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* fix: guard Globe_.init() inside rAF to prevent double instantiation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.6-20260512 [version bump]

* feat(plugins): per-plugin deps, lazy tool loading, validation, shared discovery

Phase 3 — Plugin config validation + override warnings:
- New API/pluginValidation.js with validatePluginConfig() for tool, component,
  and backend manifests. Validates required fields (name, paths), object/string
  shape of paths, dependencies block (npm/python.pip/python.conda), and warns
  on unknown top-level fields.
- updateTools()/updateComponents() now skip invalid plugins and emit override
  warnings (matching what components already logged for tools).

Phase 2 — Shared discoverPlugins() utility:
- New API/pluginDiscovery.js consolidates the duplicated scanning logic from
  updateTools(), updateComponents(), and getBackendSetups(). Supports exact-
  name and substring container patterns, JSON/require/no-op loaders, and skips
  dot/underscore-prefixed dirs.
- updateTools.js and setups.js refactored on top of the shared helper.

Phase 1 — Per-plugin dependency declaration + build-time aggregation:
- Plugin config.json may now declare a 'dependencies' block (npm + python.pip +
  python.conda). validatePluginConfig() also validates this shape.
- New scripts/resolve-plugin-deps.js scans every tool/component/backend plugin
  and writes plugin-package.json, plugin-python-requirements.txt, and
  plugin-conda-deps.txt. Detects version conflicts and fails loudly.
- scripts/build.js calls resolvePluginDeps() before updateTools().
- Dockerfile installs the aggregated plugin npm and pip deps after the root
  npm ci, using --no-save / --no-package-lock / --ignore-scripts so the root
  lockfile is untouched.
- Animation tool migrated: ffmpeg/gifshot/html2canvas now declared in its
  config.json (kept in root package.json for transitional compat).
- Generated artifacts gitignored.

Phase 4 — Lazy loading of tool bundles:
- updateTools() now emits dynamic-import arrow functions in the generated
  src/pre/tools.js with webpackChunkName hints so each tool is split into
  its own chunk (Kinds stays static because it's required synchronously).
- ToolController_ gains ensureToolLoaded(name) and getLoadedTool(name) helpers
  and makeTool is async; init/finalizeTools and the separated-tool auto-open
  flow are updated to handle lazy modules.
- Toolbar.jsx, SeparatedTools.jsx, SitesTool.js, and Layers_.js migrated to
  resolve LayersTool/etc. via the new helpers instead of poking toolModules
  directly.

Tests & docs:
- tests/fixtures/test-plugin-tools/{TestPlugin,InvalidPlugin,OverridePlugin}
  + tests/helpers/plugin-helpers.js with install/uninstall helpers.
- New unit specs: pluginValidation, pluginDiscovery, updateTools,
  resolvePluginDeps, toolLazyLoading (57 tests, all passing).
- CONTRIBUTING.md and docs/pages/Contributing/Contributing.md updated with
  schema, override behaviour, dependency declaration, build-time aggregation,
  conflict detection, and Docker integration.

* chore: bump version to 5.0.7-20260512 [version bump]

* fix: make Globe_.init() idempotent against multi-init

Globe_.init() previously constructed a fresh GlobeRenderer on every call,
which after #71 could happen multiple times for a single toggle (uiStore
setTimeout + TopBar rAF). Each extra construction appends another
.cesium-widget / _lithosphere_scene to #globe and leaves event handlers
wired to dereferenced renderer state, which has been observed to break
LithoSphere globe control buttons on configurations where the globe panel
starts closed at boot.

Add a top-of-init() guard that bails out and calls invalidateSize() when
a renderer already exists. Single small, surgical change; no behavior
change for the !L_.hasGlobe mock-swap path or for first-time construction.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.7-20260512 [version bump]

* test(plugins): generate src/pre/tools.js on demand in toolLazyLoading spec

The Playwright unit-tests CI step runs before `npm run build` so the
gitignored `src/pre/tools.js` artifact does not yet exist on disk.
Add a beforeAll hook that invokes `updateTools()` to regenerate it
when missing, keeping the spec self-contained on both CI and dev
machines that already built locally.

* fix(tools): defensive getTool() + preload flag for cross-referenced tools

Devin Review flagged a behavioural regression introduced by Phase 4:
`ToolController_.getTool(name)` previously always returned a method-
callable object (real module or `{ use(){} }` stub) because every tool
was statically imported. After Phase 4, unresolved lazy loaders are
`() => import(...)` functions, so callers like `Map_.getTool('InfoTool').use(...)`,
`mmgisAPI.getTool('DrawTool').filesOn`, and `LegendTool` calling
`LayersTool.populateCogScale` would crash with TypeError until the
target tool was opened.

Two fixes:

1. **Defensive getTool()**: Returns the legacy fallback stub when the
   tool module is still a lazy-loader function, and fires off
   `ensureToolLoaded(name)` in the background so subsequent calls see
   the resolved module. Prevents all crashes immediately.

2. **`preload: true` config flag**: Tools reached synchronously from
   other code paths (Info, Draw, Layers, Chemistry) now declare
   `"preload": true` in their `config.json`. `ToolController_.init()`
   calls `preloadEagerTools()` which fires `ensureToolLoaded` for
   every such tool right after toolbar setup — the chunks download
   in parallel with the rest of the page becoming interactive, so by
   the time a user clicks a feature the InfoTool module is already
   resolved.

`validatePluginConfig` now accepts `preload` as a known tool field;
CONTRIBUTING.md and docs/pages/Contributing/Contributing.md updated to
document when to set it. Added a unit test covering the defensive
getTool behaviour and the `preload` propagation through
`toolConfigs`.

* chore: bump version to 5.0.8-20260512 [version bump]

* revert(plugins): remove Phase 4 lazy tool loading and preload mechanism

Phase 4 lazy emission caused cross-tool consumers (Map_ feature-click,
mmgisAPI, LegendTool) to receive raw '() => import(...)' arrows from
ToolController_.getTool(), breaking InfoTool open. Reverting to the
pre-Phase-4 behavior of static tool imports.

- API/updateTools.js: generated src/pre/tools.js now emits
  'import FooTool from ...' for every tool (Kinds stays static too).
- ToolController_.js: getTool/makeTool back to sync; ensureToolLoaded,
  getLoadedTool, preloadEagerTools deleted; separated-tool auto-open
  flow simplified to direct sync calls.
- Toolbar.jsx, SeparatedTools.jsx, Layers_.js: revert async/lazy
  patterns to sync ToolController_.toolModules[name] access.
- API/pluginValidation.js: drop 'preload' from KNOWN_FIELDS.
- src/essence/Tools/{Info,Draw,Layers,Chemistry}/config.json: drop
  'preload: true'.
- CONTRIBUTING.md + docs: remove preload documentation.
- tests/unit/toolLazyLoading.spec.js: rewrite to verify static
  imports instead of lazy loaders.

Also: log standard backends at startup (parity with plugin backends
and with tools/components), so all backends now produce
'info  Loaded backend: <name> from <container>' at boot.

Phases 1-3 (per-plugin dependency aggregation, shared discoverPlugins,
config validation + override warnings) are unaffected.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(logger): new 'loaded' level (purple bg) for tool/component/backend startup

Previously the 'Loaded tool/component/backend: X from Y' lines used
the generic blue 'info' tag. They now use a dedicated 'loaded' level
rendered with a purple (#a855f7) background, so plugin discovery
output is visually distinct from other info messages.

- API/logger.js: add 'loaded' case to the dev-mode switch (white text
  on purple bg) and suppress the redundant 'Caller:' echo for it
  (matches how 'info' and 'success' are handled).
- API/updateTools.js: registerPlugin now logs at level 'loaded'.
  Drops the redundant 'Loaded ' prefix since the level tag now reads
  'loaded'.
- API/setups.js: standard and plugin backend startup logs use the
  new level, same drop of the 'Loaded ' prefix.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(logs): 'Plugging in Tools/Components/Backends...' headings

Rename the cyan banner messages in scripts/build.js and scripts/server.js
from 'Updating Tools...' / 'Updating Components...' to 'Plugging in
Tools...' / 'Plugging in Components...' so the headings match the
plugin terminology used everywhere else (plugin-package.json,
discoverPlugins, etc.).

Also add a matching 'Plugging in Backends...' banner before
setups.getBackendSetups() in scripts/server.js so backends get an
equivalent title block to tools and components.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(logs): cyan banner lines lead with a blank line instead of trailing one

Move the \n from the end to the beginning of every cyan banner in
scripts/build.js and scripts/server.js (Resolving Plugin Dependencies,
Plugging in Tools/Components/Backends, Validating Environment
Variables, Starting websocket, Starting the development server) so
that the blank line visually separates each section above its title
rather than below it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(plugins): postinstall hook auto-installs plugin npm deps

Plain `npm install` (or `npm ci`) on a fresh clone now resolves and
installs every plugin's declared npm dependencies automatically, so
new developers don't need to remember a second command.

- scripts/install-plugin-deps.js (new): reads plugin-package.json,
  filters out deps already declared in root package.json with the
  same version specifier (no-op for the Animation transitional case),
  installs the remainder with `npm install --no-save --no-package-lock
  --ignore-scripts <pkg@ver> ...`. `--no-package-lock` keeps the
  root lockfile clean; `--ignore-scripts` prevents the inner install
  from re-entering postinstall and matches the Dockerfile.
- package.json: postinstall guards against the Dockerfile's
  package.json-only layer (`scripts/` not copied yet) by checking
  for the two script files via `node -e` before invoking them.
  Adds a `plugins:install` npm script for on-demand runs.
- CONTRIBUTING.md + docs/pages/Contributing/Contributing.md: replace
  the manual-install paragraph with a note about the postinstall
  hook and the filtering behavior.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs(plugins): manual Python install step for plugin pip/conda deps

Document that the npm postinstall hook only handles plugin npm deps —
plugin pip/conda deps must be installed manually after creating the
Python environment, since there's no portable way to detect which
interpreter or environment to target from a Node script.

- CONTRIBUTING.md: added a 'For local development ... Python' block
  with the explicit `node scripts/resolve-plugin-deps.js` +
  `micromamba run -n mmgis pip install -r plugin-python-requirements.txt`
  + optional conda install commands.
- docs/pages/Contributing/Contributing.md: matching short blurb in
  the user-facing docs.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs(install): plugin Python install step in Installation.md

Add a numbered step in docs/pages/Setup/Installation/Installation.md's
Setup sequence (right after `micromamba activate mmgis`) with the
`pip install -r plugin-python-requirements.txt` and optional
`micromamba install --file plugin-conda-deps.txt` commands, so
non-Docker installs have the step in their main flow rather than
buried in CONTRIBUTING.md.

Also adds a pointer from the Python Environment section earlier in
the same file (after the env-create + activate steps) back to the
numbered Setup step.

CONTRIBUTING.md and docs/pages/Contributing/Contributing.md are
slimmed: instead of duplicating the install commands, both now link
to the Installation page (this matches the user request — the install
commands live in installation docs, not contribution docs).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: serialize concurrent layer reloads and stop mutating layer.url

Concurrent mmgisAPI.reloadLayer() calls for the same layer were
silently dropped by the _layersBeingMade guard, and reloadLayer
mutated layer.url in-place during async work — causing a race where
a second reload would capture the resolved URL as its 'original' and
permanently corrupt the {starttime}/{endtime} template placeholders.

Changes:
- TimeControl.reloadLayer: compute resolvedUrl into a local variable
  instead of mutating layer.url. Pass resolvedUrl through to
  Map_.refreshLayer.
- Map_.refreshLayer: accept resolvedUrl; temporarily swap layer.url
  inside a try/finally for the makeLayer call so the fetched URL is
  the resolved one and the placeholder template is always restored.
- Map_.refreshLayer: when a reload is already in flight for the
  same layer, queue the request (coalesced by name) instead of
  dropping it with a 'Cannot make layer' warning.
- Map_.makeLayer: after releasing the lock, drain any queued reload
  for this layer via setTimeout 0.
- TimeControl.reloadTimeLayers: become async and await Promise.all
  of every per-layer reload; remove the setTimeout(500) workaround
  around active-feature restoration and follow-pan logic.
- mmgisAPI.reloadLayers: new batch API that reloads multiple
  time-enabled layers concurrently and returns a Promise<boolean[]>.
- Tests: new tests/e2e/map/concurrent-layer-reload.spec.js covers
  the seven scenarios from the plan (single reload, URL preservation,
  multi-layer concurrent reload, rapid same-layer reload, reloadLayers
  API surface). tests/e2e/api/mmgis-api.spec.js gains a surface
  check that reloadLayer/reloadLayers are exposed.
- Docs: Main.md documents the new reloadLayers entry.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.8-20260513 [version bump]

* fix(TimeControl): use Promise.allSettled in reloadTimeLayers

Promise.all short-circuits on first rejection, which would skip the
active-feature restoration and follow-pan logic if any single layer
reload threw (network error, malformed config, etc.). The old
setTimeout(500) approach ran those steps unconditionally; switching
to Promise.allSettled preserves that robustness.

Addresses Devin Review feedback on PR #78.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mmgisAPI): use Promise.allSettled in reloadLayers batch API

Mirrors the reloadTimeLayers fix: a single failing TimeControl.reloadLayer
call (e.g. unknown layer name throws inside asLayerUUID, network error,
malformed config) no longer rejects the whole batch. The returned array
preserves order and reports failed entries as false instead, matching
the documented Promise<boolean[]> contract.

Addresses second Devin Review finding on PR #78.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: note that reloadTimeLayers is now async (Promise<string[]>)

The implementation changed from sync to async in commit a096cfe9 (the
function now uses await + Promise.allSettled internally to coordinate
per-layer reloads), but the public API JSDoc and Main.md still
documented the old synchronous return type. External consumers using
the old synchronous return value would get a Promise instead of an
array.

Updates JSDoc on mmgisAPI.reloadTimeLayers to declare
Promise<string[]>, and rewrites the Main.md example to use 'await'.
Also fixes the previous example, which had a syntactically-malformed
trailing tuple-style index.

Addresses third Devin Review finding on PR #78.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: split time-related e2e specs into tests/e2e/time/

Moves the two time-feature specs out of tests/e2e/map/ so the map suite
stays focused on map-UI behavior and the time/time-enabled-layer suite
can be run (and reasoned about) on its own:

- tests/e2e/map/time-control.spec.js
    -> tests/e2e/time/time-control.spec.js
- tests/e2e/map/concurrent-layer-reload.spec.js
    -> tests/e2e/time/concurrent-layer-reload.spec.js

Relative imports (../../helpers, ../../pages, ../../fixtures) are
unchanged because the new directory is the same depth. Playwright
picks the files up automatically via testDir './tests' +
testMatch '**/*.spec.js'.

Adds a matching 'npm run test:e2e:time' script and documents the new
suite in tests/README.md alongside the existing per-suite scripts.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: cap local Playwright workers at 4

The previous `workers: process.env.CI ? 1 : undefined` resolved to
Playwright's default (~half the CPU cores). On higher-core machines
(e.g. 16 cores -> 8 workers) the dev server gets overloaded and the
suite actually runs slower. CI behavior is unchanged (1 worker).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: thread resolvedUrl through makeLayer/captureVector instead of mutating layer.url

The previous fix in Map_.refreshLayer temporarily swapped
`layerObj.url = resolvedUrl` during the `await makeLayer` call and
restored it in a finally block. Since `layerObj` is the shared
`L_.layers.data[name]` object, any concurrent code reading
`layer.url` during that async window could observe the resolved URL
instead of the template. Most importantly, a second
`TimeControl.reloadLayer()` call would then capture the resolved
URL as its 'template' and corrupt the placeholders for every
subsequent reload.

Surfaced by tests/e2e/time/concurrent-layer-reload.spec.js Test 5,
which after Promise.all of two reloads observed
`layer.url ==='geodatasets:...?from=...&to=...'` instead of the
expected `{starttime}/{endtime}` template.

Fix: thread `resolvedUrl` as an explicit parameter through
`Map_.refreshLayer` -> `makeLayer` -> `makeVectorLayer` ->
`captureVector` (via options.resolvedUrl). `captureVector` uses
`options.resolvedUrl` when provided and skips the
`{starttime}`/`{endtime}`/`{customtime.*}` regex replacement
(which TimeControl.reloadLayer already performed). `layer.url` is
NEVER mutated for the duration of the async operation, so the
template is preserved across overlapping reloads.

This also fully resolves the Devin Review #4 finding which flagged
the temporary URL swap as reintroducing the same race the PR was
meant to fix.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: also replace {time} placeholder when computing resolvedUrl

In the previous fix, captureVector skips its time-replacement block when
the caller supplies options.resolvedUrl (because TimeControl.reloadLayer
already performed those replacements). However TimeControl.reloadLayer
was only replacing {starttime}/{endtime}/{customtime.*} on resolvedUrl,
not {time} — which captureVector previously handled at
LayerCapturer.js:97 by mapping {time} -> endTime. This caused vector
layers using the documented {time} placeholder (see
docs/pages/Configure/Layers/Tile/Tile.md:90 and
docs/pages/APIs/JavaScript/Main/Main.md:482) to fetch URLs containing
the literal text '{time}' on time-triggered reloads.

Mirror the existing captureVector behavior: replace {time} with the
formatted end-time value alongside {starttime}/{endtime}, before the
resolved URL is threaded through Map_.refreshLayer -> makeLayer ->
captureVector.

Addresses Devin Review finding on commit fcba8101.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: always run time-placeholder replacement in captureVector (idempotent)

The previous fix gated captureVector's time-placeholder replacement
block on `!hasResolvedUrl`, on the assumption that any caller passing
options.resolvedUrl had already done the replacement.

That assumption only holds for time.type === 'global' / 'requery' /
forceRequery. For other time types that still flow through
Map_.refreshLayer into captureVector (most importantly
time.type === 'local' with endProp == null per TimeControl.js:276-287),
TimeControl.reloadLayer's resolved-URL replacement block at lines 249-273
is skipped, so the resolvedUrl arrives at captureVector still containing
literal {starttime}/{endtime}/{time} placeholders. The fetch then goes
out with unreplaced placeholders.

Fix: drop the !hasResolvedUrl guard and always run the replacement,
reading the source from `layerUrl` (which is already either
options.resolvedUrl or layerObj.url per the choice above). The
.replace(/{starttime}/g, ...) chain is idempotent on URLs that have
already been resolved — the regexes simply don't match — so the
correct path is restored without re-introducing the mutate-in-place
bug fcba8101 fixed.

Addresses Devin Review finding on commit ddb90dbb.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: remove GIBS MODIS time tile test (always skips on external dependency)

The test at tests/e2e/time/time-control.spec.js depended on
gibs.earthdata.nasa.gov being reachable from the test environment and
served only as a placeholder — it skips every run since the test
infrastructure does not have external network access by policy. It
provides no signal in CI or locally, so removing it reduces noise in
the suite output without losing coverage.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: add coverage for {time}, local-endProp-null, stress, and feature-presence cases

Four new e2e tests in tests/e2e/time/concurrent-layer-reload.spec.js,
each targeting a gap the original suite missed:

Test 8 — {time} placeholder preservation. Mirrors Test 2/5 but uses
`{time}` instead of `{starttime}/{endtime}`. Catches the Devin
Review #5 regression where captureVector's gating on !hasResolvedUrl
silently dropped the {time} -> endTime replacement (the literal
'{time}' would have ended up in the fetch URL).

Test 9 — local + endProp==null path. Sets layer.time.type='local'
and layer.time.endProp=null to force the TimeControl.reloadLayer
branch (TimeControl.js:276-287) that bypasses the resolved-URL
placeholder block and falls through to the else clause. Inspects
outgoing /geodatasets/* requests via page.on('request', ...) and
asserts no literal {starttime}/{endtime}/{time} remain. Catches the
Devin Review #6 regression: when this branch hit captureVector with
hasResolvedUrl=true, the !hasResolvedUrl gate previously short-
circuited the only remaining replacement site.

Test 10 — 20-reload stress burst. Extends Test 4's two-reload check
to 20 concurrent reloadLayer() calls, capturing 'Cannot make layer'
warnings to verify the queue coalesces requests instead of silently
dropping them. Also re-asserts layer.url template integrity post-
burst.

Test 11 — Feature-presence after concurrent reload. Captures
L_.layers.layer[key].getLayers().length before and after a 5-reload
burst. Asserts the count is still > 0 afterwards — the user-visible
'gaps where dynamically-appearing data doesn't show up' symptom from
the original bug report.

All four tests skip gracefully when their fixture layer is absent or
the dataset returns no rows, to avoid spurious failures across
mission configurations.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: add 5 edge-case tests probing the blast radius of the URL fix

Five additional tests in tests/e2e/time/concurrent-layer-reload.spec.js
covering paths adjacent to the URL-mutation fix that were not exercised
by tests 1-11. Each was chosen because the production code on that path
changed (or now has a different contract) and a regression would not be
caught by the original race-condition tests.

Test 12 — {customtime.N} placeholder preservation. TimeControl.reloadLayer's
customtime replacement loop was migrated from `layer.url = ...` to
`resolvedUrl = ...`. Seeds TimeControl.customTimes.times so the loop
actually runs, then asserts the {customtime.0} placeholder remains
literally on layer.url after reload.

Test 13 — mmgisAPI.reloadTimeLayers() returns a Promise. This is the
backward-incompatible behavior change documented in
docs/pages/APIs/JavaScript/Main/Main.md (previously synchronous).
Asserts the returned value is a thenable that resolves to an array,
pinning the new contract so it does not silently regress.

Test 14 — mmgisAPI.reloadLayers handles unknown layer names. The
Promise.allSettled change requires that a failing per-layer reload
surfaces as `false` at the same array position as its input name,
without throwing. Mixes a valid name + an unknown name + another
valid name to verify the order and the boolean mapping.

Test 15 — Reloading a time-DISABLED vector layer leaves layer.url
unchanged. Discovers a candidate layer from L_.layers.data at runtime
(skips if none exist), reloads it, and asserts the URL is byte-equal
afterwards. Catches any accidental URL mutation introduced for the
non-time code path.

Test 16 — mmgisAPI.reloadLayers handles empty array, null, undefined,
and string inputs without throwing — verifying the Array.isArray guard
at mmgisAPI.js:618. Returns `[]` in all cases.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(makeLayer): wrap dispatch in try/finally so lock + queue drain always run

Address Devin Review finding: a thrown exception from any layer-type
builder (makeVectorLayer, makeVelocityLayer, etc.) inside makeLayer's
switch statement previously left lockRegistry[layerName] set to true
and skipped the queue drain entirely, since the release statement and
queue-drain block both lived AFTER the awaited dispatch.

Effect of the bug: any subsequent refreshLayer call for that layer
would queue against a permanently-locked entry that never drains.
The new queue mechanism inherits this pre-existing issue and makes
the failure mode worse — silent accumulation in _layerReloadQueue
instead of a visible 'Cannot make layer' warning.

Additional concern: the outer 'new Promise(async (resolve, reject) =>
{...})' is the async-executor anti-pattern. A throw inside the async
executor escapes to the unhandled-rejection handler instead of
rejecting the outer Promise — so the caller's 'awa…
* fix: update MobileCoordButton topBar paddingLeft from 80px to 34px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: MobileTimeUIToggle — inline toggle logic, float right, hide from settings on mobile

- Replace broken Coordinates.toggleTimeUI() call with direct jQuery/store toggle
- Float time button right in toolbar
- Hide Time UI toggle from settings modal on mobile (toolbar has it)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: push scalebar/compass/scale up 40px on mobile, keep #timeUI in DOM

- BottomElementPositioner: position mapToolBar, leaflet-bottom-left/right
  40px above bottom on mobile (above toolbar)
- Stop removing #timeUI from DOM on mobile so MobileTimeUIToggle works

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — only show endtime, always expanded

- Hide #mmgisTimeUIStartWrapper and StartWrapperFake on mobile via CSS
- Force expanded state (addClass expanded + show) when toggling TimeUI on
- CSS ensures #timeUI.active always shows expanded content on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI opens in tool panel with header, end time, expanded rows

- MobileTimeUIToggle now opens/closes the tool panel via ToolController_
- Closes any active tool before showing TimeUI
- Forces expanded state when opening
- CSS hides start time inputs, positions expanded content properly
- Overrides absolute positioning of expanded content for tool panel flow

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: rewrite separated tools system from jQuery to React components

- Add separatedToolsList/activeSeparatedTools state to Zustand uiStore
- Rewrite SeparatedTools.jsx with glassmorphism panels, CSS Module styling
- Replace SepToolsContainer (setInterval hack) with SepToolButton/SepToolsSection
- Remove ~170 lines of jQuery DOM construction from ToolController_.js
- Fix hardcoded rgba(26,26,27,0.88) to theme-aware var(--color-a-rgb)
- Remove separated tool entries from themeApplier.js
- Remove separated tool overrides from FloatingElements.css
- Move Legend CSS overrides from Toolbar.module.css to SeparatedTools.module.css
- Remove jQuery active-state manipulation from IdentifierTool.js
- Add store sync in Map_.js displayOnStart logic
- Preserve all DOM IDs for backward compatibility (mmgisAPI, tool make())

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260501 [version bump]

* fix: TimeUI mobile checks — use Zustand store instead of L_.UserInterface_

L_.UserInterface_ is null when TimeUI.init() runs (TimeControl.init is called
before L_.link sets UserInterface_). All 16 isMobile checks now read from
useUIStore.getState().isMobile which is set at startup.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: move displayOnStart logic from Map_.js to ToolController_.finalizeTools()

- Map_ no longer references specific tools (LegendTool)
- displayOnStart is now handled generically for all separated tools
- Added DOM element polling (tryMake) to handle React render timing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* revert: remove all TimeUI-related mobile changes

Reverts TimeUI.js and BottomBar.js to development base.
Restores #timeUI DOM removal in UserInterfaceBridge.fina().
Removes MobileTimeUIToggle component from Toolbar.jsx.
Removes TimeUI mobile CSS overrides from UserInterfaceMobile_.css.
Non-TimeUI refinements (toolbar height, scalebar positioning, etc.) preserved.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* simplify: remove DOM polling, use simple setTimeout(0) for auto-open

LegendTool handles its own content lifecycle via subscribeOnLayerToggle.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: mobile TimeUI — fix isMobile detection, staging container, toolbar toggle

- TimeUI.js: import useUIStore and replace all 16 L_.UserInterface_?.isMobile
  checks with useUIStore.getState().isMobile (L_.UserInterface_ is null when
  TimeUI.init() runs, so mobile conditionals were dead code)
- TimeUI.js: stage mobile #timeUI in hidden #timeUIMobileStaging instead of
  placing directly in #tools (which gets cleared by other tools)
- UserInterfaceBridge.js: stop removing #timeUI from DOM on mobile
- Toolbar.jsx: add MobileTimeUIToggle that moves #timeUI between staging and
  #tools, opens/closes tool panel via ToolController_
- BottomBar.js: hide TimeUI toggle from settings modal on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: rescue #timeUI back to staging when another tool opens

Subscribe to activeToolName changes — when a tool becomes active while
TimeUI is showing, move #timeUI back to #timeUIMobileStaging before
the new tool's make() clears #tools.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove separatedTool/justification config toggles, fix review issues

- Remove separatedTool checkbox and justification dropdown from Legend
  and Identifier config.json (these are always separated, not configurable)
- Remove justification property/code from LegendTool.js, IdentifierTool.js
- Simplify Globe_.js separated tool count (no justification filter)
- Remove justification from Reference-Mission config blueprint
- Update LegendTool help docs and Legend.md documentation
- Add --color-a-rgb fallback (29,31,32) in SeparatedTools.module.css
- Add display:none !important to .panelIdentifier to prevent 12px gap
- Update e2e test comment

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: circular import in TimeUI.js, toolbar/bottomFloatingBar position sync

- TimeUI.js: replace top-level useUIStore import with lazy _getUIStore()
  accessor to avoid 'Cannot access useUIStore before initialization'
  circular import error at _remakeTimeSlider
- SplitScreens.jsx: skip #timeUI reparenting observer on mobile (mobile
  uses MobileTimeUIToggle to manage #timeUI placement in #tools)
- BottomElementPositioner.jsx: unify mobile transition to 0.3s (matches
  toolsWrapper and toolbar), guard pxIsTools against undefined
- Toolbar.jsx: align toolbar transition to 0.3s ease-out

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* LegendTool fix empty message

* chore: remove separated tools offset logic from Globe_.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: skip _makeHistogram on mobile (no timeline slider, timestamps unset)

_makeHistogram renders inside the timeline slider which doesn't exist
on mobile. Without it, _timelineStartTimestamp is NaN, causing
'Invalid time value' RangeError at toISOString().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — populate expanded rows, fix Invalid date, fix panel height

- TimeUI.js attachEvents: use _initialStart/_initialEnd on mobile (same
  as desktop) instead of L_.TimeControl_ which isn't set yet at init time.
  Fixes 'Invalid date' in start/end time inputs.
- TimeUI.js fina: set expanded=true on mobile and call _populateExpandedRows()
  so year/month/day/hour rows actually render. Removed position:absolute and
  pointer-events:none overrides.
- Toolbar.jsx: set tool panel height to 217px (TimeUI.height) instead of
  45% viewport — matches actual TimeUI content height.
- UserInterfaceMobile_.css: expanded content flows naturally (position:relative),
  hide start time inputs, allow overflow scroll, flex-wrap topbar.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI justify-content center, restore toolbar border-bottom

- Add justify-content: center to #mmgisTimeUIMain on mobile
- Remove border-bottom: none override so toolbar keeps its default border

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI overflow hidden, scalebar/compass fixed at 40px offset

- #timeUI overflow-y: hidden (was auto, causing 2px scroll)
- Scalebar/compass/map controls stay at fixed 40px offset (above toolbar)
  regardless of tool panel state — no longer shift up by pxIsTools

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Implement multi-tier knowledge architecture

- Restructure AGENTS.md from 745 lines to 106 lines (Tier 1: essential context)
- Create knowledge/ directory with 30+ wiki-style documentation files (Tier 2: deep knowledge)
- Create knowledge/reference/ with 8 detailed reference files (Tier 3: lookup material)
- Move AI-GETTING-STARTED.md and AI-DEVELOPMENT.md to knowledge/
- Update all file references in .specify/templates and blueprints
- Create knowledge/README.md as the full knowledge base index
- Create knowledge/reference/README.md as reference material index

Three-tier knowledge discovery system:
  Tier 1: AGENTS.md (~106 lines) - scannable in <2 minutes
  Tier 2: knowledge/*.md - deep knowledge on architecture, tools, APIs, DB, infra
  Tier 3: knowledge/reference/*.md - coding conventions, API reference, troubleshooting

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: mobile toolbar active button style matches desktop, fix icon alignment

- All mobile toolbar buttons (ToolButton, MobileCoordButton, MobileTimeUIToggle)
  now use display:flex with align-items/justify-content center for proper
  vertical icon centering
- MobileCoordButton: changed 'active' class to 'toolButtonActive' to match
  the global CSS active style (color-mmgis + color-i background)
- Removed inline color overrides so CSS .toolButtonActive takes effect

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add Devin knowledge notes from past MMGIS sessions

Include curated lessons learned from past Devin sessions:
- CI/CD: ignore build-arm64/amd64 failures, focus on required checks
- Child sessions: no separate PRs when consolidating
- ENV triple-update rule (.env, sample.env, ENVs.md)
- Error handling: use logger with infrastructure_error for fatal startup errors
- Path traversal security: stay within /Missions, handle subpath serving
- Database initialization architecture and migration patterns
- API authentication behavior across AUTH modes
- Auto-generated MMGIS concept index

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar active button style, icon alignment, tool deactivation

- Active toolbar buttons get desktop-matching margin (1px 0) and
  border-radius (8px) via .toolButton.toolButtonActive CSS rule
- Removed line-height: 40px from .toolButton (flex centering handles
  vertical alignment, line-height was pushing icons up)
- MobileCoordButton now watches activeToolName store and deactivates
  when another tool opens (fixes coords staying active)
- MobileTimeUIToggle sets activeToolName='MobileTimeUI' when opening
  so coords/other buttons can detect it and deactivate
- MobileTimeUIToggle clears activeToolName when closing
- Both custom buttons skip self-deactivation via name check

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix cross-references: convert backtick refs to markdown links, add Devin knowledge notes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon height 40px, button margins for active padding

- #toolbar .toolButton i: height 40px fixes icon vertical alignment
- #toolbar .toolButton: margin 0 2px gives spacing between buttons
- #toolbar .toolButton.toolButtonActive: margin 1px 2px so active
  background has visual padding around the icon

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename knowledge/ to .knowledge/ for consistency with .specify/ convention

Dot-prefix signals agent infrastructure (not source code), consistent with
.specify/, .github/, .vscode/ conventions. All cross-references updated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon line-height 40px, active button padding via height

- Coord and TimeUI button <i> icons get line-height: 40px
- Active buttons: height 34px (vs 40px toolbar) creates visual padding
  around the active background, centered by flex align-items
- Buttons get margin: 0 1px for horizontal spacing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix broken cross-reference: 06.2 -> 06.1-configure-rest-api.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: close active tool + cancel deferred cleanup in MobileCoordButton/TimeUI

- MobileCoordButton: call closeActiveTool() before opening, destroy
  _pendingCloseTool if set, increment _closeSeq to cancel deferred
  tools.innerHTML clear
- MobileTimeUIToggle: same _pendingCloseTool + _closeSeq fix after
  closeActiveTool() to prevent 420ms deferred cleanup from wiping
  #timeUI after it's placed in #tools
- Removed redundant closeActiveTool() from MobileCoordButton close path
  (was being called after destroy, not needed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: active mobile toolbar buttons 34x34px (square)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Drastically compress .knowledge/ — keep only unique agent content

Remove 33 wiki files that duplicate docs/pages/ content.
Remove 9 reference/ files derivable from source code.
Keep only 5 files (down from 46):
  - AI-GETTING-STARTED.md (agent setup walkthrough)
  - AI-DEVELOPMENT.md (spec-kit workflow)
  - conventions-and-gotchas.md (naming, code style, common issues)
  - 12-devin-knowledge-notes.md (CI, auth, DB init, security gotchas)
  - README.md (index pointing to docs/pages/ for everything else)

Principle: don't duplicate docs/ — only keep what's uniquely agent-optimized.
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename to knowledge-notes.md, remove Devin branding and fork-specific CI section

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: hide mmgis-map-logo on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore Database Safety Rules for AI Agents section in AGENTS.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: shift compass and map scale 6px to the right (both mobile and desktop)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add back Important Instructions, code pattern templates, and detailed project structure

- Important Instructions in AGENTS.md: MCP tools, hot-reload, Reference Mission
- .knowledge/code-patterns.md: full directory tree with key directory annotations,
  plus copy-paste templates for Express routes, Sequelize models, Tool plugins,
  and WebSocket handlers

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Update project structure trees to reflect current filesystem

Add missing directories: tests/, .knowledge/, .specify/, .github/, views/,
private/, spice/, build/, examples/, scripts/middleware.js.
Both abbreviated (AGENTS.md) and detailed (.knowledge/code-patterns.md) trees
now match the actual repo layout.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.30-20260501 [version bump]

* Add Layers_.js to project structure (key singleton L_)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix project structure: correct API layout, frontend modules, code templates

API/Backend/ uses feature-domain modules (Draw/, Users/, Config/, etc.)
with setup.js + routes/ + models/ per feature — not APIs/ or Databases/.
Frontend essence/ has Components/, Helpers/, LandingPage/, mmgisAPI/,
services/ — not Ancillary/. Basics/ includes all singletons (Globe_,
Formulae_, ToolController_, Viewer_, ComponentController_, Test_).
Code templates updated to match actual patterns (setup.js, module.exports).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove test infrastructure (Test_ module, testModules, DrawTool.test)

- Delete src/essence/Basics/Test_/ directory
- Delete src/essence/Tools/Draw/DrawTool.test.js
- Remove Test_ import and Shift+T keydown handler from essence.js
- Remove tests key from Draw tool config.json
- Remove testModules generation logic from API/updateTools.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.31-20260501 [version bump]

* style: move Cesium link button to top-right and match Leaflet zoom button styling

- Change control container from top-left to top-right positioning
- Update button size from 26px to 30px to match Leaflet zoom controls
- Use CSS variables (--color-a, --color-f, --color-mmgis) instead of hardcoded colors
- Add border-radius and box-shadow matching Leaflet control appearance
- Update hover/inactive states to use themed colors

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: anchor map logo to viewport instead of Leaflet map panel

- Change MapLogo parent from .leaflet-bottom.leaflet-right to #main-container
- Switch CSS position from absolute to fixed for viewport anchoring
- Add explicit bottom-offset positioning in BottomElementPositioner (desktop)
- Add explicit bottom-offset positioning in BottomElementPositioner (mobile)
- Logo stays at viewport right edge regardless of open side panels
- Retains smooth bottom offset transitions when bottom bar appears

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: remove references to deleted test infrastructure (Test_, DrawTool.test)

- Remove Test_/ from project structure in .knowledge/code-patterns.md
- Remove DrawTool.test.js references from specs/006 spec, plan, and tasks
- Remove Draw Tool Testing section from tasks.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.32-20260501 [version bump]

* fix: append logo to document.body to avoid filter containing block

#main-container has a CSS filter property which creates a new containing
block per the CSS spec, causing position:fixed to behave like absolute.
Appending to document.body ensures true viewport-fixed positioning.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: prevent mobile topBarTitleName text wrapping by replacing max-width with white-space: nowrap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.33-20260501 [version bump]

* chore: bump version to 5.0.0 and update changelog

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(ui): move Screenshot/Fullscreen to BottomBar, About to TopBar kebab

TopBar kebab menu now contains only Keyboard Shortcuts, Settings, and About
(About now shows on both desktop and mobile).

BottomBarReact now renders Screenshot, Fullscreen, and Copy Link buttons
(top to bottom) following the same IconButton + Tooltip pattern. The
About button has been removed from BottomBarReact.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(mobile): enforce exclusive panel toggling on mobile in TopBar

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* style: reposition LithoSphere globe controls to match Leaflet/Cesium theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(topbar): hide Viewer/Globe toggles based on configured panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): reorder buttons (Copy Link, Screenshot, Fullscreen) and unify size

Reorder the BottomBarReact buttons top-to-bottom to: Copy Link, Screenshot,
Fullscreen.

Move the 24x24 button sizing from the #topBarLink id selector in mmgis.css
into the .barButton class in BottomBarReact.module.css so all three buttons
share the same compact size as the original Copy Link button. Drop the now
redundant #topBarLink rule.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): increase padding-bottom to 12px and button margin to 3px 0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: rearrange globe controls — compass top-right circular, nav row, vertical column, panels open left

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* chore: bump version to 5.0.2-20260505 [version bump]

* style: anchor observe settings panel right:34px and float nav hover panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(theming): add 5 new themes, --color-shadow variable, and configure ThemePreview

- Add Dark Terra, Dark Nebula, Dark Lunar, Dark Supernova, Light Botanical themes
- Add --color-shadow CSS variable to every theme + :root fallback
- Replace hardcoded rgba shadow colors with var(--color-shadow) in TopBar,
  Toolbar, SeparatedTools, ToolPanel, FloatingElements, Dropdown, Modal,
  and SplitScreens
- Add Custom shadowcolor color picker in tab-ui-config and apply it via Stylize
- Add ThemePreview component (configure/src) wired through Maker.js as
  a new 'themepreview' row type so the configure UI shows a live mini
  mockup of the selected theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* fix(configure/ThemePreview): tighten top spacing and live-preview Custom theme

- Pull the preview up by 12px so the gap below the theme dropdown is tighter.
- Read the Custom color pickers (look.primarycolor / secondarycolor /
  tertiarycolor / accentcolor / shadowcolor / topbarcolor / toolbarcolor /
  mapcolor) from the configuration and overlay them on Dark Default so
  the preview reflects Custom theme edits live, matching Stylize.js's
  runtime behavior.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260505 [version bump]

* feat(themes): add Dark Heliosphere, Dark Monokai, and Light Solarized

- Dark Heliosphere: deep night purple surface with corona-orange accent.
- Dark Monokai: warm graphite surface with lime accent (Monokai-inspired).
- Light Solarized: classic solarized base3/base02 with blue accent.

Mirror added to configure/src/themes/themes.js for the ThemePreview, and
the three names appended to the Color Theme dropdown options.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(coordinates): respect time.initiallyOpen when live deep-link is set

* chore: bump version to 5.0.3-20260505 [version bump]

* refactor(theming): remove Custom theme + per-field color overrides

- Drop the 'Custom' option from the Color Theme dropdown.
- Remove all Custom Color Options (look.primarycolor, .secondarycolor,
  .tertiarycolor, .accentcolor, .bodycolor, .topbarcolor, .toolbarcolor,
  .mapcolor, .hightlightcolor, .shadowcolor) from tab-ui-config.json.
- Strip the matching DOM/CSS-variable override block from Stylize.js;
  Stylize now just applies the selected preset theme (and the page logo).
- Drop the empty bodycolor/topbarcolor/toolbarcolor/mapcolor/shadowcolor
  defaults from API/templates/config_template.js.
- Simplify ThemePreview to render the selected preset directly — no
  Custom branch, no overlay logic.

Preset themes cover all the looks we want and keep the configure surface
much smaller.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(time-ui): round corners on TimeUI shell, action wrappers, mode dropdown

- #timeUI: 10px border-radius on the outer time control bar.
- #mmgisTimeUIActionsLeft / #mmgisTimeUIActionsRight: 10px border-radius
  so the action clusters sit as rounded chips.
- #mmgisTimeUIActionsRight > div (excluding #mmgisTimeUIPresent): 10px
  border-radius on each action button so they match the wrapper.
- #mmgisTimeUIModeDropdown: 40px height + 10px border-radius to align
  with the rest of the bar; clear the dropy default border-color so the
  rounded edge isn't outlined.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260505 [version bump]

* feat(configure): mark light themes as (experimental) in dropdown label

Light themes still have outstanding contrast issues, so flag them in the
Color Theme dropdown without changing the saved value.

- Maker dropdown now accepts options as either a plain string (current
  behavior) or { value, label } so the rendered label can differ from
  the persisted value.
- tab-ui-config switches the six light themes to { value, label } form
  with '(experimental)' appended to the label only. Existing mission
  configs that already saved 'Light Default' etc. continue to match.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix timeUI border radius

* fix(mobile): rescue #timeUI before tool make() destroys it

Clicking Layers -> Time -> Layers -> Time on mobile caused the bottom
panel to render LayersTool content with TimeUI height. The #timeUI DOM
element was destroyed when LayersTool.make() called $('#tools').empty(),
before the async React useEffect in MobileTimeUIToggle could rescue it
to its staging container.

- ToolController_.makeTool: synchronously move #timeUI from #tools back
  to #timeUIMobileStaging (and reset TimeUI store flags) on mobile,
  before invoking the new tool's make().
- MobileTimeUIToggle.handleClick: defensive fallback that re-initializes
  TimeUI if #timeUI no longer exists when the toggle is activated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): move re-initialized #timeUI from staging into #tools

TimeUI.init() on mobile appends the new #timeUI to the hidden
#timeUIMobileStaging container, so the fallback branch must also move
it into #tools — otherwise the user sees an empty tool panel after
the destroyed-element recovery path.

Caught by Devin Review.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): preserve #timeUI when Coordinates tool empties #tools

On mobile, opening or closing the Coordinates tool runs
$('#tools').empty() inside interfaceWithMMWebGIS / separateFromMMWebGIS.
After the previous PR commits, clicking Coordinates -> Time still left
the bottom panel empty because:

- Coordinates.make() empties #tools while #timeUI is in staging (fine
  on its own), but the Coordinates teardown that fires after the user
  switches to the Time toggle (via MobileCoordButton's useEffect on
  activeToolName change) calls Coordinates.destroy() ->
  separateFromMMWebGIS(), which empties #tools wholesale and destroys
  the freshly-placed #timeUI.

Add a rescueMobileTimeUI() helper that moves #timeUI from #tools back
to #timeUIMobileStaging before each tools.empty() call in Coordinates,
mirroring the rescue already done in ToolController_.makeTool().

Coordinates -> Time now correctly shows the TimeUI.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): harden TimeUI fallback recovery (call fina(), de-dupe popovers)

Devin Review correctly flagged that the safety-net path in
MobileTimeUIToggle.handleClick was producing a partially-broken TimeUI
when it fired:

- TimeUI.init() unconditionally appends a new #timeUIPlayPopover_global
  to <body>, so a second init() left two elements with the same id.
- TimeUI.init() alone does not wire up date pickers or per-button click
  handlers — that's TimeUI.fina()'s job. Without fina(), the recovered
  TimeUI rendered visually but Play / Previous / Next / Fit / Follow /
  Present / Expand were all dead.

Before re-initializing, remove the stale #timeUIPlayPopover_global and
#timeUIQuickSelectPopover_global divs to avoid duplicate ids. After the
new #timeUI is moved into #tools, call TimeUI.fina() to populate the
date pickers, attach the button click handlers, build the histogram,
and populate the expanded mobile rows.

Some delegated body/document handlers in attachEvents() will still be
duplicated on this path; that is acceptable for a degraded recovery
that should never run in practice now that the primary rescues in
ToolController_.makeTool() and Coordinates.js cover all known paths.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260505 [version bump]

* fix(mobile): Coordinates teardown only removes its own DOM

The previous Coordinates fix was racing with itself: after the Time
toggle synchronously moved #timeUI into #tools, MobileCoordButton's
useEffect (triggered by the activeToolName change) ran on the next
React tick and called L_.Coordinates.destroy(). That called
separateFromMMWebGIS(), whose rescue moved #timeUI right back into the
hidden staging div before tools.empty() — so the bottom panel ended up
empty even though the time toggle was 'active'.

Make separateFromMMWebGIS selective: only remove the
Coordinates-specific DOM (#coordUIHeader and #CoordinatesDiv) instead
of wiping all of #tools. Any other content already in #tools (e.g.
#timeUI placed there by the Time toggle) is left alone.

interfaceWithMMWebGIS still keeps the rescue + tools.empty() pattern
on the open path so Coordinates always starts from a clean panel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Bump DrawTool Temporal Drawings upward

* chore: bump version to 5.0.6-20260505 [version bump]

* chore: reset version to 5.0.0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test(e2e): fix 9 pre-existing failures (test-only changes)

- mmgis-api.spec.js: add form-fill login under AUTH=local; serialize
  describe to avoid concurrent-login race in the session store
- coordinates.spec.js: TimeUI toggle was moved from the coordinates bar
  to the Settings modal; navigate via topbar kebab menu and assert the
  checkbox is rendered
- widgets.spec.js: target .leaflet-control-zoom-in/-out specifically;
  the bare .leaflet-control-zoom class is also used by the home/reset
  control, so the original assertion was always false
- sites.spec.js: scope panel selector to #toolPanel; both the toolbar
  icon and the panel container share id="SitesTool"

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* Revert "chore: bump version to 5.0.1-20260505 [version bump]"

This reverts commit 4880204c1163be5d1d7fa96d14a0ed018c6f586c.

* fix: prevent filter operator dropdown clipping in Layers panel

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260507 [version bump]

* revert: keep dropy openUp:true for operator dropdowns

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit d67c369ed437e47d658ae051348d377978dc48ed.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 29565ed829a55e9c241a789c9a3901d11cb5ca67.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 50e357604ebe9378564619b34c508b63cfb62c1d.

* chore: bump version to 5.0.1-20260507 [version bump]

* chore: bump version to 5.0.2-20260511 [version bump]

* fix: render Globe panel immediately on first open without window resize

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260511 [version bump]

* feat: add theme borders to panels and gradient backgrounds to splitters

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: bump split shadow gradient opacity to 0.4

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal 3-col grid + smaller leaflet zoom button gap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: prevent hotkey label/value wrapping (ellipsis instead)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal single column, no wrap, no truncation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: hotkeys modal dividers, invert title/subtitle colors, rename title, margin above subtitles

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: move splitter gradient to themed CSS class, restore hover feedback

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* style: hotkeys section titles use --color-h (matches rest of app)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* fix: guard Globe_.init() inside rAF to prevent double instantiation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.6-20260512 [version bump]

* feat(plugins): per-plugin deps, lazy tool loading, validation, shared discovery

Phase 3 — Plugin config validation + override warnings:
- New API/pluginValidation.js with validatePluginConfig() for tool, component,
  and backend manifests. Validates required fields (name, paths), object/string
  shape of paths, dependencies block (npm/python.pip/python.conda), and warns
  on unknown top-level fields.
- updateTools()/updateComponents() now skip invalid plugins and emit override
  warnings (matching what components already logged for tools).

Phase 2 — Shared discoverPlugins() utility:
- New API/pluginDiscovery.js consolidates the duplicated scanning logic from
  updateTools(), updateComponents(), and getBackendSetups(). Supports exact-
  name and substring container patterns, JSON/require/no-op loaders, and skips
  dot/underscore-prefixed dirs.
- updateTools.js and setups.js refactored on top of the shared helper.

Phase 1 — Per-plugin dependency declaration + build-time aggregation:
- Plugin config.json may now declare a 'dependencies' block (npm + python.pip +
  python.conda). validatePluginConfig() also validates this shape.
- New scripts/resolve-plugin-deps.js scans every tool/component/backend plugin
  and writes plugin-package.json, plugin-python-requirements.txt, and
  plugin-conda-deps.txt. Detects version conflicts and fails loudly.
- scripts/build.js calls resolvePluginDeps() before updateTools().
- Dockerfile installs the aggregated plugin npm and pip deps after the root
  npm ci, using --no-save / --no-package-lock / --ignore-scripts so the root
  lockfile is untouched.
- Animation tool migrated: ffmpeg/gifshot/html2canvas now declared in its
  config.json (kept in root package.json for transitional compat).
- Generated artifacts gitignored.

Phase 4 — Lazy loading of tool bundles:
- updateTools() now emits dynamic-import arrow functions in the generated
  src/pre/tools.js with webpackChunkName hints so each tool is split into
  its own chunk (Kinds stays static because it's required synchronously).
- ToolController_ gains ensureToolLoaded(name) and getLoadedTool(name) helpers
  and makeTool is async; init/finalizeTools and the separated-tool auto-open
  flow are updated to handle lazy modules.
- Toolbar.jsx, SeparatedTools.jsx, SitesTool.js, and Layers_.js migrated to
  resolve LayersTool/etc. via the new helpers instead of poking toolModules
  directly.

Tests & docs:
- tests/fixtures/test-plugin-tools/{TestPlugin,InvalidPlugin,OverridePlugin}
  + tests/helpers/plugin-helpers.js with install/uninstall helpers.
- New unit specs: pluginValidation, pluginDiscovery, updateTools,
  resolvePluginDeps, toolLazyLoading (57 tests, all passing).
- CONTRIBUTING.md and docs/pages/Contributing/Contributing.md updated with
  schema, override behaviour, dependency declaration, build-time aggregation,
  conflict detection, and Docker integration.

* chore: bump version to 5.0.7-20260512 [version bump]

* fix: make Globe_.init() idempotent against multi-init

Globe_.init() previously constructed a fresh GlobeRenderer on every call,
which after #71 could happen multiple times for a single toggle (uiStore
setTimeout + TopBar rAF). Each extra construction appends another
.cesium-widget / _lithosphere_scene to #globe and leaves event handlers
wired to dereferenced renderer state, which has been observed to break
LithoSphere globe control buttons on configurations where the globe panel
starts closed at boot.

Add a top-of-init() guard that bails out and calls invalidateSize() when
a renderer already exists. Single small, surgical change; no behavior
change for the !L_.hasGlobe mock-swap path or for first-time construction.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.7-20260512 [version bump]

* test(plugins): generate src/pre/tools.js on demand in toolLazyLoading spec

The Playwright unit-tests CI step runs before `npm run build` so the
gitignored `src/pre/tools.js` artifact does not yet exist on disk.
Add a beforeAll hook that invokes `updateTools()` to regenerate it
when missing, keeping the spec self-contained on both CI and dev
machines that already built locally.

* fix(tools): defensive getTool() + preload flag for cross-referenced tools

Devin Review flagged a behavioural regression introduced by Phase 4:
`ToolController_.getTool(name)` previously always returned a method-
callable object (real module or `{ use(){} }` stub) because every tool
was statically imported. After Phase 4, unresolved lazy loaders are
`() => import(...)` functions, so callers like `Map_.getTool('InfoTool').use(...)`,
`mmgisAPI.getTool('DrawTool').filesOn`, and `LegendTool` calling
`LayersTool.populateCogScale` would crash with TypeError until the
target tool was opened.

Two fixes:

1. **Defensive getTool()**: Returns the legacy fallback stub when the
   tool module is still a lazy-loader function, and fires off
   `ensureToolLoaded(name)` in the background so subsequent calls see
   the resolved module. Prevents all crashes immediately.

2. **`preload: true` config flag**: Tools reached synchronously from
   other code paths (Info, Draw, Layers, Chemistry) now declare
   `"preload": true` in their `config.json`. `ToolController_.init()`
   calls `preloadEagerTools()` which fires `ensureToolLoaded` for
   every such tool right after toolbar setup — the chunks download
   in parallel with the rest of the page becoming interactive, so by
   the time a user clicks a feature the InfoTool module is already
   resolved.

`validatePluginConfig` now accepts `preload` as a known tool field;
CONTRIBUTING.md and docs/pages/Contributing/Contributing.md updated to
document when to set it. Added a unit test covering the defensive
getTool behaviour and the `preload` propagation through
`toolConfigs`.

* chore: bump version to 5.0.8-20260512 [version bump]

* revert(plugins): remove Phase 4 lazy tool loading and preload mechanism

Phase 4 lazy emission caused cross-tool consumers (Map_ feature-click,
mmgisAPI, LegendTool) to receive raw '() => import(...)' arrows from
ToolController_.getTool(), breaking InfoTool open. Reverting to the
pre-Phase-4 behavior of static tool imports.

- API/updateTools.js: generated src/pre/tools.js now emits
  'import FooTool from ...' for every tool (Kinds stays static too).
- ToolController_.js: getTool/makeTool back to sync; ensureToolLoaded,
  getLoadedTool, preloadEagerTools deleted; separated-tool auto-open
  flow simplified to direct sync calls.
- Toolbar.jsx, SeparatedTools.jsx, Layers_.js: revert async/lazy
  patterns to sync ToolController_.toolModules[name] access.
- API/pluginValidation.js: drop 'preload' from KNOWN_FIELDS.
- src/essence/Tools/{Info,Draw,Layers,Chemistry}/config.json: drop
  'preload: true'.
- CONTRIBUTING.md + docs: remove preload documentation.
- tests/unit/toolLazyLoading.spec.js: rewrite to verify static
  imports instead of lazy loaders.

Also: log standard backends at startup (parity with plugin backends
and with tools/components), so all backends now produce
'info  Loaded backend: <name> from <container>' at boot.

Phases 1-3 (per-plugin dependency aggregation, shared discoverPlugins,
config validation + override warnings) are unaffected.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(logger): new 'loaded' level (purple bg) for tool/component/backend startup

Previously the 'Loaded tool/component/backend: X from Y' lines used
the generic blue 'info' tag. They now use a dedicated 'loaded' level
rendered with a purple (#a855f7) background, so plugin discovery
output is visually distinct from other info messages.

- API/logger.js: add 'loaded' case to the dev-mode switch (white text
  on purple bg) and suppress the redundant 'Caller:' echo for it
  (matches how 'info' and 'success' are handled).
- API/updateTools.js: registerPlugin now logs at level 'loaded'.
  Drops the redundant 'Loaded ' prefix since the level tag now reads
  'loaded'.
- API/setups.js: standard and plugin backend startup logs use the
  new level, same drop of the 'Loaded ' prefix.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(logs): 'Plugging in Tools/Components/Backends...' headings

Rename the cyan banner messages in scripts/build.js and scripts/server.js
from 'Updating Tools...' / 'Updating Components...' to 'Plugging in
Tools...' / 'Plugging in Components...' so the headings match the
plugin terminology used everywhere else (plugin-package.json,
discoverPlugins, etc.).

Also add a matching 'Plugging in Backends...' banner before
setups.getBackendSetups() in scripts/server.js so backends get an
equivalent title block to tools and components.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(logs): cyan banner lines lead with a blank line instead of trailing one

Move the \n from the end to the beginning of every cyan banner in
scripts/build.js and scripts/server.js (Resolving Plugin Dependencies,
Plugging in Tools/Components/Backends, Validating Environment
Variables, Starting websocket, Starting the development server) so
that the blank line visually separates each section above its title
rather than below it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(plugins): postinstall hook auto-installs plugin npm deps

Plain `npm install` (or `npm ci`) on a fresh clone now resolves and
installs every plugin's declared npm dependencies automatically, so
new developers don't need to remember a second command.

- scripts/install-plugin-deps.js (new): reads plugin-package.json,
  filters out deps already declared in root package.json with the
  same version specifier (no-op for the Animation transitional case),
  installs the remainder with `npm install --no-save --no-package-lock
  --ignore-scripts <pkg@ver> ...`. `--no-package-lock` keeps the
  root lockfile clean; `--ignore-scripts` prevents the inner install
  from re-entering postinstall and matches the Dockerfile.
- package.json: postinstall guards against the Dockerfile's
  package.json-only layer (`scripts/` not copied yet) by checking
  for the two script files via `node -e` before invoking them.
  Adds a `plugins:install` npm script for on-demand runs.
- CONTRIBUTING.md + docs/pages/Contributing/Contributing.md: replace
  the manual-install paragraph with a note about the postinstall
  hook and the filtering behavior.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs(plugins): manual Python install step for plugin pip/conda deps

Document that the npm postinstall hook only handles plugin npm deps —
plugin pip/conda deps must be installed manually after creating the
Python environment, since there's no portable way to detect which
interpreter or environment to target from a Node script.

- CONTRIBUTING.md: added a 'For local development ... Python' block
  with the explicit `node scripts/resolve-plugin-deps.js` +
  `micromamba run -n mmgis pip install -r plugin-python-requirements.txt`
  + optional conda install commands.
- docs/pages/Contributing/Contributing.md: matching short blurb in
  the user-facing docs.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs(install): plugin Python install step in Installation.md

Add a numbered step in docs/pages/Setup/Installation/Installation.md's
Setup sequence (right after `micromamba activate mmgis`) with the
`pip install -r plugin-python-requirements.txt` and optional
`micromamba install --file plugin-conda-deps.txt` commands, so
non-Docker installs have the step in their main flow rather than
buried in CONTRIBUTING.md.

Also adds a pointer from the Python Environment section earlier in
the same file (after the env-create + activate steps) back to the
numbered Setup step.

CONTRIBUTING.md and docs/pages/Contributing/Contributing.md are
slimmed: instead of duplicating the install commands, both now link
to the Installation page (this matches the user request — the install
commands live in installation docs, not contribution docs).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: serialize concurrent layer reloads and stop mutating layer.url

Concurrent mmgisAPI.reloadLayer() calls for the same layer were
silently dropped by the _layersBeingMade guard, and reloadLayer
mutated layer.url in-place during async work — causing a race where
a second reload would capture the resolved URL as its 'original' and
permanently corrupt the {starttime}/{endtime} template placeholders.

Changes:
- TimeControl.reloadLayer: compute resolvedUrl into a local variable
  instead of mutating layer.url. Pass resolvedUrl through to
  Map_.refreshLayer.
- Map_.refreshLayer: accept resolvedUrl; temporarily swap layer.url
  inside a try/finally for the makeLayer call so the fetched URL is
  the resolved one and the placeholder template is always restored.
- Map_.refreshLayer: when a reload is already in flight for the
  same layer, queue the request (coalesced by name) instead of
  dropping it with a 'Cannot make layer' warning.
- Map_.makeLayer: after releasing the lock, drain any queued reload
  for this layer via setTimeout 0.
- TimeControl.reloadTimeLayers: become async and await Promise.all
  of every per-layer reload; remove the setTimeout(500) workaround
  around active-feature restoration and follow-pan logic.
- mmgisAPI.reloadLayers: new batch API that reloads multiple
  time-enabled layers concurrently and returns a Promise<boolean[]>.
- Tests: new tests/e2e/map/concurrent-layer-reload.spec.js covers
  the seven scenarios from the plan (single reload, URL preservation,
  multi-layer concurrent reload, rapid same-layer reload, reloadLayers
  API surface). tests/e2e/api/mmgis-api.spec.js gains a surface
  check that reloadLayer/reloadLayers are exposed.
- Docs: Main.md documents the new reloadLayers entry.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.8-20260513 [version bump]

* fix(TimeControl): use Promise.allSettled in reloadTimeLayers

Promise.all short-circuits on first rejection, which would skip the
active-feature restoration and follow-pan logic if any single layer
reload threw (network error, malformed config, etc.). The old
setTimeout(500) approach ran those steps unconditionally; switching
to Promise.allSettled preserves that robustness.

Addresses Devin Review feedback on PR #78.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mmgisAPI): use Promise.allSettled in reloadLayers batch API

Mirrors the reloadTimeLayers fix: a single failing TimeControl.reloadLayer
call (e.g. unknown layer name throws inside asLayerUUID, network error,
malformed config) no longer rejects the whole batch. The returned array
preserves order and reports failed entries as false instead, matching
the documented Promise<boolean[]> contract.

Addresses second Devin Review finding on PR #78.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: note that reloadTimeLayers is now async (Promise<string[]>)

The implementation changed from sync to async in commit a096cfe9 (the
function now uses await + Promise.allSettled internally to coordinate
per-layer reloads), but the public API JSDoc and Main.md still
documented the old synchronous return type. External consumers using
the old synchronous return value would get a Promise instead of an
array.

Updates JSDoc on mmgisAPI.reloadTimeLayers to declare
Promise<string[]>, and rewrites the Main.md example to use 'await'.
Also fixes the previous example, which had a syntactically-malformed
trailing tuple-style index.

Addresses third Devin Review finding on PR #78.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: split time-related e2e specs into tests/e2e/time/

Moves the two time-feature specs out of tests/e2e/map/ so the map suite
stays focused on map-UI behavior and the time/time-enabled-layer suite
can be run (and reasoned about) on its own:

- tests/e2e/map/time-control.spec.js
    -> tests/e2e/time/time-control.spec.js
- tests/e2e/map/concurrent-layer-reload.spec.js
    -> tests/e2e/time/concurrent-layer-reload.spec.js

Relative imports (../../helpers, ../../pages, ../../fixtures) are
unchanged because the new directory is the same depth. Playwright
picks the files up automatically via testDir './tests' +
testMatch '**/*.spec.js'.

Adds a matching 'npm run test:e2e:time' script and documents the new
suite in tests/README.md alongside the existing per-suite scripts.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: cap local Playwright workers at 4

The previous `workers: process.env.CI ? 1 : undefined` resolved to
Playwright's default (~half the CPU cores). On higher-core machines
(e.g. 16 cores -> 8 workers) the dev server gets overloaded and the
suite actually runs slower. CI behavior is unchanged (1 worker).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: thread resolvedUrl through makeLayer/captureVector instead of mutating layer.url

The previous fix in Map_.refreshLayer temporarily swapped
`layerObj.url = resolvedUrl` during the `await makeLayer` call and
restored it in a finally block. Since `layerObj` is the shared
`L_.layers.data[name]` object, any concurrent code reading
`layer.url` during that async window could observe the resolved URL
instead of the template. Most importantly, a second
`TimeControl.reloadLayer()` call would then capture the resolved
URL as its 'template' and corrupt the placeholders for every
subsequent reload.

Surfaced by tests/e2e/time/concurrent-layer-reload.spec.js Test 5,
which after Promise.all of two reloads observed
`layer.url ==='geodatasets:...?from=...&to=...'` instead of the
expected `{starttime}/{endtime}` template.

Fix: thread `resolvedUrl` as an explicit parameter through
`Map_.refreshLayer` -> `makeLayer` -> `makeVectorLayer` ->
`captureVector` (via options.resolvedUrl). `captureVector` uses
`options.resolvedUrl` when provided and skips the
`{starttime}`/`{endtime}`/`{customtime.*}` regex replacement
(which TimeControl.reloadLayer already performed). `layer.url` is
NEVER mutated for the duration of the async operation, so the
template is preserved across overlapping reloads.

This also fully resolves the Devin Review #4 finding which flagged
the temporary URL swap as reintroducing the same race the PR was
meant to fix.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: also replace {time} placeholder when computing resolvedUrl

In the previous fix, captureVector skips its time-replacement block when
the caller supplies options.resolvedUrl (because TimeControl.reloadLayer
already performed those replacements). However TimeControl.reloadLayer
was only replacing {starttime}/{endtime}/{customtime.*} on resolvedUrl,
not {time} — which captureVector previously handled at
LayerCapturer.js:97 by mapping {time} -> endTime. This caused vector
layers using the documented {time} placeholder (see
docs/pages/Configure/Layers/Tile/Tile.md:90 and
docs/pages/APIs/JavaScript/Main/Main.md:482) to fetch URLs containing
the literal text '{time}' on time-triggered reloads.

Mirror the existing captureVector behavior: replace {time} with the
formatted end-time value alongside {starttime}/{endtime}, before the
resolved URL is threaded through Map_.refreshLayer -> makeLayer ->
captureVector.

Addresses Devin Review finding on commit fcba8101.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: always run time-placeholder replacement in captureVector (idempotent)

The previous fix gated captureVector's time-placeholder replacement
block on `!hasResolvedUrl`, on the assumption that any caller passing
options.resolvedUrl had already done the replacement.

That assumption only holds for time.type === 'global' / 'requery' /
forceRequery. For other time types that still flow through
Map_.refreshLayer into captureVector (most importantly
time.type === 'local' with endProp == null per TimeControl.js:276-287),
TimeControl.reloadLayer's resolved-URL replacement block at lines 249-273
is skipped, so the resolvedUrl arrives at captureVector still containing
literal {starttime}/{endtime}/{time} placeholders. The fetch then goes
out with unreplaced placeholders.

Fix: drop the !hasResolvedUrl guard and always run the replacement,
reading the source from `layerUrl` (which is already either
options.resolvedUrl or layerObj.url per the choice above). The
.replace(/{starttime}/g, ...) chain is idempotent on URLs that have
already been resolved — the regexes simply don't match — so the
correct path is restored without re-introducing the mutate-in-place
bug fcba8101 fixed.

Addresses Devin Review finding on commit ddb90dbb.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: remove GIBS MODIS time tile test (always skips on external dependency)

The test at tests/e2e/time/time-control.spec.js depended on
gibs.earthdata.nasa.gov being reachable from the test environment and
served only as a placeholder — it skips every run since the test
infrastructure does not have external network access by policy. It
provides no signal in CI or locally, so removing it reduces noise in
the suite output without losing coverage.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: add coverage for {time}, local-endProp-null, stress, and feature-presence cases

Four new e2e tests in tests/e2e/time/concurrent-layer-reload.spec.js,
each targeting a gap the original suite missed:

Test 8 — {time} placeholder preservation. Mirrors Test 2/5 but uses
`{time}` instead of `{starttime}/{endtime}`. Catches the Devin
Review #5 regression where captureVector's gating on !hasResolvedUrl
silently dropped the {time} -> endTime replacement (the literal
'{time}' would have ended up in the fetch URL).

Test 9 — local + endProp==null path. Sets layer.time.type='local'
and layer.time.endProp=null to force the TimeControl.reloadLayer
branch (TimeControl.js:276-287) that bypasses the resolved-URL
placeholder block and falls through to the else clause. Inspects
outgoing /geodatasets/* requests via page.on('request', ...) and
asserts no literal {starttime}/{endtime}/{time} remain. Catches the
Devin Review #6 regression: when this branch hit captureVector with
hasResolvedUrl=true, the !hasResolvedUrl gate previously short-
circuited the only remaining replacement site.

Test 10 — 20-reload stress burst. Extends Test 4's two-reload check
to 20 concurrent reloadLayer() calls, capturing 'Cannot make layer'
warnings to verify the queue coalesces requests instead of silently
dropping them. Also re-asserts layer.url template integrity post-
burst.

Test 11 — Feature-presence after concurrent reload. Captures
L_.layers.layer[key].getLayers().length before and after a 5-reload
burst. Asserts the count is still > 0 afterwards — the user-visible
'gaps where dynamically-appearing data doesn't show up' symptom from
the original bug report.

All four tests skip gracefully when their fixture layer is absent or
the dataset returns no rows, to avoid spurious failures across
mission configurations.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: add 5 edge-case tests probing the blast radius of the URL fix

Five additional tests in tests/e2e/time/concurrent-layer-reload.spec.js
covering paths adjacent to the URL-mutation fix that were not exercised
by tests 1-11. Each was chosen because the production code on that path
changed (or now has a different contract) and a regression would not be
caught by the original race-condition tests.

Test 12 — {customtime.N} placeholder preservation. TimeControl.reloadLayer's
customtime replacement loop was migrated from `layer.url = ...` to
`resolvedUrl = ...`. Seeds TimeControl.customTimes.times so the loop
actually runs, then asserts the {customtime.0} placeholder remains
literally on layer.url after reload.

Test 13 — mmgisAPI.reloadTimeLayers() returns a Promise. This is the
backward-incompatible behavior change documented in
docs/pages/APIs/JavaScript/Main/Main.md (previously synchronous).
Asserts the returned value is a thenable that resolves to an array,
pinning the new contract so it does not silently regress.

Test 14 — mmgisAPI.reloadLayers handles unknown layer names. The
Promise.allSettled change requires that a failing per-layer reload
surfaces as `false` at the same array position as its input name,
without throwing. Mixes a valid name + an unknown name + another
valid name to verify the order and the boolean mapping.

Test 15 — Reloading a time-DISABLED vector layer leaves layer.url
unchanged. Discovers a candidate layer from L_.layers.data at runtime
(skips if none exist), reloads it, and asserts the URL is byte-equal
afterwards. Catches any accidental URL mutation introduced for the
non-time code path.

Test 16 — mmgisAPI.reloadLayers handles empty array, null, undefined,
and string inputs without throwing — verifying the Array.isArray guard
at mmgisAPI.js:618. Returns `[]` in all cases.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(makeLayer): wrap dispatch in try/finally so lock + queue drain always run

Address Devin Review finding: a thrown exception from any layer-type
builder (makeVectorLayer, makeVelocityLayer, etc.) inside makeLayer's
switch statement previously left lockRegistry[layerName] set to true
and skipped the queue drain entirely, since the release statement and
queue-drain block both lived AFTER the awaited dispatch.

Effect of the bug: any subsequent refreshLayer call for that layer
would queue against a permanently-locked entry that never drains.
The new queue mechanism inherits this pre-existing issue and makes
the failure mode worse — silent accumulation in _layerReloadQueue
instead of a visible 'Cannot make layer' warning.

Additional concern: the outer 'new Promise(async (resolve, reject) =>
{...})' is the async-executor anti-pattern. A throw inside the async
executor escapes to the unhandled-rejection handler instead of
rejecting the outer Promise — so the caller's 'await makeLayer(...)'
would hang indefinitely, compounding the lock-leak symptom.

Fix:
  - Wrap the type-dispatch switch + Filtering.updateGeoJSON/
    triggerFilter calls in a try/catch/finally.
  - catch logs the error and tracks success via 'madeSuccessfully'.
  - finally runs unconditionally: lockRegistry[layerName] = false,
    drain L_._layerReloadQueue[layerObj.name] if present, then
    resolve(madeSuccessfully). This ensures the lock release and
    queue drain happen regardless of whether the inner builder
    threw or completed normally.

Test (concurrent-layer-reload.spec.js):
  Added probe-style test '_layersBeingMade lock is released after
  single and concurrent reloads' that asserts the lock invariant:
    1. After mmgisAPI.reloadLayer() resolves +
       a 100ms drain window, _layersBeingMade[key] is false.
    2. After 5 concurrent mmgisAPI.reloadLayer() calls resolve +
       a 1000ms drain window, _layersBeingMade[key] is false.
    3. _layerReloadQueue is empty afterwards (otherwise a future
       reload would mistakenly trigger an immediate drain instead
       of doing its own work).

  This is a positive-invariant test — it catches accidental lock
  retention even without force-triggering exceptions, which would
  require monkey-patching webpack-internal module references that
  aren't exposed on window.

Per user direction, NOT addressing Devin Review's separate finding
about the reloadTimeLayers sync->async breaking change at this
time (no version bump requested).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.9-20260513 [version bump]

* fix(OperationsClock): bump z-index above bottomFloatingBar so clock stays visible

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(plugin-deps): respect override semantics when aggregating dependencies

When a plugin tool/component/backend overrides a standard one by reusing
the same directory name, only the override's deps should contribute to
the aggregated plugin manifests. Previously, gatherDependencies()
concatenated standard + plugin entries and fed both to mergeNpm/
mergePython, which could spuriously flag the same package as
conflicting between the standard and override versions.

Extract winnersByName() (mirroring API/updateTools.js +
API/setups.js override behavior) and use it for all three plugin
kinds. Add unit tests covering the override case and the
spurious-conflict regression.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(Dockerfile): re-install plugin npm deps in runtime stage

Plugin deps installed in the builder via `npm install --no-save
--no-package-lock` aren't recorded in package.json/package-lock.json,
so the runtime stage's `npm ci --only=production` would lose them.
Frontend deps are bundled by webpack into ./build so they're fine, but
backend plugins that `require()` their declared npm dependencies at
runtime would crash with 'Cannot find module'.

Copy plugin-package.json from the builder and re-run the same
conditional install in the runtime stage so backend plugin deps land
in the runtime image's node_modules. `--ignore-scripts` prevents the
inner install from re-entering the root postinstall hook.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(OperationsClock): lift to 58px bottom when TimeUI is open

Add #operationsClock to BottomElementPositioner's reactive positioning
so it shifts to bottom:58px when timeUIActive is true (TimeUI dock
visible) and back to bottom:40px when closed. Avoids overlap with
the bottom floating bar without adding new state to OperationsClock
itself. Mobile path is unchanged — OperationsClock.setupMobilePositioning
manages mobile positioning separately.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(OperationsClock): always sit at bottom:58px

Revert the dynamic positioning in BottomElementPositioner and just
hardcode bottom:58px in OperationsClock.css. Simpler, no cross-cutting
state dependency.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add Lunar South Pole reference mission variant (IAU2000:30120)

- Add REFERENCE_MISSION_VARIANTS registry and resolveVariantBlueprintPath
  helper to missionTemplates.js for dynamic variant resolution
- Update configs.js to accept referenceMissionVariant parameter and
  validate against the registry
- Add variant dropdown to NewMissionModal UI when Reference Mission
  checkbox is enabled
- Create blueprint directory with south polar stereographic config
  (IAU2000:30120, +proj=stere +lat_0=-90, bounds ±1095700/1095600)
- Add unit tests for variant registry, blueprint path resolution, and
  Lunar-SouthPole projection assertions (15 tests)
- Add E2E smoke tests for Lunar-SouthPole mission variant

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(api): pass through reloadLayer flags in reloadLayers

Forward evenIfOff, evenIfControlled, forceRequery, and
skipOrderedBringToFront parameters to TimeControl.reloadLayer()
for each layer in the batch. Fully backward-compatible — existing
callers that pass only layerNames get undefined for all flags.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.10-20260518 [version bump]

* chore: bump version to 5.0.10-20260518 [version bump]

* docs(api): add forceRequery & skipOrderedBringToFront to reloadLayer/reloadLayers docs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add SPole basemap to lunar ref mission

* chore: bump version to 5.0.11-20260518 [version bump]

* chore: remove unused blueprints/Missions/Test directory

The Test blueprint is no longer needed — the Reference-Mission and
Reference-Mission-Lunar-SouthPole blueprints serve as the basis for
demo, development, and testing.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: use setupReferenceMission string value as variant key fallback

Address Devin Review feedback: when setupReferenceMission is a non-empty
string (e.g. 'Lunar-SouthPole'), use it as the variant key if
referenceMissionVariant is not provided. Also guard against empty string
triggering reference mission creation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(measure): rename config DEM field from 'dem' to 'url' and handle HTTP URLs

- Rename 'field': 'dem' → 'url' and 'name': 'DEM Path' → 'DEM URL' in
  src/essence/Tools/Measure/config.json (layer-specific DEM objectarray)
- Rename same in configure/src/metaconfigs/layer-tile-config.json
- Add http:// and https:// prefix handling in makeProfile() so external
  URLs are not mangled with the mission path prefix

The top-level variables.dem field (prima…
…986)

* fix: mobile TimeUI opens in tool panel with header, end time, expanded rows

- MobileTimeUIToggle now opens/closes the tool panel via ToolController_
- Closes any active tool before showing TimeUI
- Forces expanded state when opening
- CSS hides start time inputs, positions expanded content properly
- Overrides absolute positioning of expanded content for tool panel flow

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: rewrite separated tools system from jQuery to React components

- Add separatedToolsList/activeSeparatedTools state to Zustand uiStore
- Rewrite SeparatedTools.jsx with glassmorphism panels, CSS Module styling
- Replace SepToolsContainer (setInterval hack) with SepToolButton/SepToolsSection
- Remove ~170 lines of jQuery DOM construction from ToolController_.js
- Fix hardcoded rgba(26,26,27,0.88) to theme-aware var(--color-a-rgb)
- Remove separated tool entries from themeApplier.js
- Remove separated tool overrides from FloatingElements.css
- Move Legend CSS overrides from Toolbar.module.css to SeparatedTools.module.css
- Remove jQuery active-state manipulation from IdentifierTool.js
- Add store sync in Map_.js displayOnStart logic
- Preserve all DOM IDs for backward compatibility (mmgisAPI, tool make())

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.28-20260501 [version bump]

* fix: TimeUI mobile checks — use Zustand store instead of L_.UserInterface_

L_.UserInterface_ is null when TimeUI.init() runs (TimeControl.init is called
before L_.link sets UserInterface_). All 16 isMobile checks now read from
useUIStore.getState().isMobile which is set at startup.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: move displayOnStart logic from Map_.js to ToolController_.finalizeTools()

- Map_ no longer references specific tools (LegendTool)
- displayOnStart is now handled generically for all separated tools
- Added DOM element polling (tryMake) to handle React render timing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* revert: remove all TimeUI-related mobile changes

Reverts TimeUI.js and BottomBar.js to development base.
Restores #timeUI DOM removal in UserInterfaceBridge.fina().
Removes MobileTimeUIToggle component from Toolbar.jsx.
Removes TimeUI mobile CSS overrides from UserInterfaceMobile_.css.
Non-TimeUI refinements (toolbar height, scalebar positioning, etc.) preserved.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* simplify: remove DOM polling, use simple setTimeout(0) for auto-open

LegendTool handles its own content lifecycle via subscribeOnLayerToggle.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: mobile TimeUI — fix isMobile detection, staging container, toolbar toggle

- TimeUI.js: import useUIStore and replace all 16 L_.UserInterface_?.isMobile
  checks with useUIStore.getState().isMobile (L_.UserInterface_ is null when
  TimeUI.init() runs, so mobile conditionals were dead code)
- TimeUI.js: stage mobile #timeUI in hidden #timeUIMobileStaging instead of
  placing directly in #tools (which gets cleared by other tools)
- UserInterfaceBridge.js: stop removing #timeUI from DOM on mobile
- Toolbar.jsx: add MobileTimeUIToggle that moves #timeUI between staging and
  #tools, opens/closes tool panel via ToolController_
- BottomBar.js: hide TimeUI toggle from settings modal on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: rescue #timeUI back to staging when another tool opens

Subscribe to activeToolName changes — when a tool becomes active while
TimeUI is showing, move #timeUI back to #timeUIMobileStaging before
the new tool's make() clears #tools.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove separatedTool/justification config toggles, fix review issues

- Remove separatedTool checkbox and justification dropdown from Legend
  and Identifier config.json (these are always separated, not configurable)
- Remove justification property/code from LegendTool.js, IdentifierTool.js
- Simplify Globe_.js separated tool count (no justification filter)
- Remove justification from Reference-Mission config blueprint
- Update LegendTool help docs and Legend.md documentation
- Add --color-a-rgb fallback (29,31,32) in SeparatedTools.module.css
- Add display:none !important to .panelIdentifier to prevent 12px gap
- Update e2e test comment

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: circular import in TimeUI.js, toolbar/bottomFloatingBar position sync

- TimeUI.js: replace top-level useUIStore import with lazy _getUIStore()
  accessor to avoid 'Cannot access useUIStore before initialization'
  circular import error at _remakeTimeSlider
- SplitScreens.jsx: skip #timeUI reparenting observer on mobile (mobile
  uses MobileTimeUIToggle to manage #timeUI placement in #tools)
- BottomElementPositioner.jsx: unify mobile transition to 0.3s (matches
  toolsWrapper and toolbar), guard pxIsTools against undefined
- Toolbar.jsx: align toolbar transition to 0.3s ease-out

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* LegendTool fix empty message

* chore: remove separated tools offset logic from Globe_.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: skip _makeHistogram on mobile (no timeline slider, timestamps unset)

_makeHistogram renders inside the timeline slider which doesn't exist
on mobile. Without it, _timelineStartTimestamp is NaN, causing
'Invalid time value' RangeError at toISOString().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — populate expanded rows, fix Invalid date, fix panel height

- TimeUI.js attachEvents: use _initialStart/_initialEnd on mobile (same
  as desktop) instead of L_.TimeControl_ which isn't set yet at init time.
  Fixes 'Invalid date' in start/end time inputs.
- TimeUI.js fina: set expanded=true on mobile and call _populateExpandedRows()
  so year/month/day/hour rows actually render. Removed position:absolute and
  pointer-events:none overrides.
- Toolbar.jsx: set tool panel height to 217px (TimeUI.height) instead of
  45% viewport — matches actual TimeUI content height.
- UserInterfaceMobile_.css: expanded content flows naturally (position:relative),
  hide start time inputs, allow overflow scroll, flex-wrap topbar.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI justify-content center, restore toolbar border-bottom

- Add justify-content: center to #mmgisTimeUIMain on mobile
- Remove border-bottom: none override so toolbar keeps its default border

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI overflow hidden, scalebar/compass fixed at 40px offset

- #timeUI overflow-y: hidden (was auto, causing 2px scroll)
- Scalebar/compass/map controls stay at fixed 40px offset (above toolbar)
  regardless of tool panel state — no longer shift up by pxIsTools

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Implement multi-tier knowledge architecture

- Restructure AGENTS.md from 745 lines to 106 lines (Tier 1: essential context)
- Create knowledge/ directory with 30+ wiki-style documentation files (Tier 2: deep knowledge)
- Create knowledge/reference/ with 8 detailed reference files (Tier 3: lookup material)
- Move AI-GETTING-STARTED.md and AI-DEVELOPMENT.md to knowledge/
- Update all file references in .specify/templates and blueprints
- Create knowledge/README.md as the full knowledge base index
- Create knowledge/reference/README.md as reference material index

Three-tier knowledge discovery system:
  Tier 1: AGENTS.md (~106 lines) - scannable in <2 minutes
  Tier 2: knowledge/*.md - deep knowledge on architecture, tools, APIs, DB, infra
  Tier 3: knowledge/reference/*.md - coding conventions, API reference, troubleshooting

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: mobile toolbar active button style matches desktop, fix icon alignment

- All mobile toolbar buttons (ToolButton, MobileCoordButton, MobileTimeUIToggle)
  now use display:flex with align-items/justify-content center for proper
  vertical icon centering
- MobileCoordButton: changed 'active' class to 'toolButtonActive' to match
  the global CSS active style (color-mmgis + color-i background)
- Removed inline color overrides so CSS .toolButtonActive takes effect

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add Devin knowledge notes from past MMGIS sessions

Include curated lessons learned from past Devin sessions:
- CI/CD: ignore build-arm64/amd64 failures, focus on required checks
- Child sessions: no separate PRs when consolidating
- ENV triple-update rule (.env, sample.env, ENVs.md)
- Error handling: use logger with infrastructure_error for fatal startup errors
- Path traversal security: stay within /Missions, handle subpath serving
- Database initialization architecture and migration patterns
- API authentication behavior across AUTH modes
- Auto-generated MMGIS concept index

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar active button style, icon alignment, tool deactivation

- Active toolbar buttons get desktop-matching margin (1px 0) and
  border-radius (8px) via .toolButton.toolButtonActive CSS rule
- Removed line-height: 40px from .toolButton (flex centering handles
  vertical alignment, line-height was pushing icons up)
- MobileCoordButton now watches activeToolName store and deactivates
  when another tool opens (fixes coords staying active)
- MobileTimeUIToggle sets activeToolName='MobileTimeUI' when opening
  so coords/other buttons can detect it and deactivate
- MobileTimeUIToggle clears activeToolName when closing
- Both custom buttons skip self-deactivation via name check

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix cross-references: convert backtick refs to markdown links, add Devin knowledge notes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon height 40px, button margins for active padding

- #toolbar .toolButton i: height 40px fixes icon vertical alignment
- #toolbar .toolButton: margin 0 2px gives spacing between buttons
- #toolbar .toolButton.toolButtonActive: margin 1px 2px so active
  background has visual padding around the icon

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename knowledge/ to .knowledge/ for consistency with .specify/ convention

Dot-prefix signals agent infrastructure (not source code), consistent with
.specify/, .github/, .vscode/ conventions. All cross-references updated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon line-height 40px, active button padding via height

- Coord and TimeUI button <i> icons get line-height: 40px
- Active buttons: height 34px (vs 40px toolbar) creates visual padding
  around the active background, centered by flex align-items
- Buttons get margin: 0 1px for horizontal spacing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix broken cross-reference: 06.2 -> 06.1-configure-rest-api.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: close active tool + cancel deferred cleanup in MobileCoordButton/TimeUI

- MobileCoordButton: call closeActiveTool() before opening, destroy
  _pendingCloseTool if set, increment _closeSeq to cancel deferred
  tools.innerHTML clear
- MobileTimeUIToggle: same _pendingCloseTool + _closeSeq fix after
  closeActiveTool() to prevent 420ms deferred cleanup from wiping
  #timeUI after it's placed in #tools
- Removed redundant closeActiveTool() from MobileCoordButton close path
  (was being called after destroy, not needed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: active mobile toolbar buttons 34x34px (square)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Drastically compress .knowledge/ — keep only unique agent content

Remove 33 wiki files that duplicate docs/pages/ content.
Remove 9 reference/ files derivable from source code.
Keep only 5 files (down from 46):
  - AI-GETTING-STARTED.md (agent setup walkthrough)
  - AI-DEVELOPMENT.md (spec-kit workflow)
  - conventions-and-gotchas.md (naming, code style, common issues)
  - 12-devin-knowledge-notes.md (CI, auth, DB init, security gotchas)
  - README.md (index pointing to docs/pages/ for everything else)

Principle: don't duplicate docs/ — only keep what's uniquely agent-optimized.
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename to knowledge-notes.md, remove Devin branding and fork-specific CI section

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: hide mmgis-map-logo on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore Database Safety Rules for AI Agents section in AGENTS.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: shift compass and map scale 6px to the right (both mobile and desktop)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add back Important Instructions, code pattern templates, and detailed project structure

- Important Instructions in AGENTS.md: MCP tools, hot-reload, Reference Mission
- .knowledge/code-patterns.md: full directory tree with key directory annotations,
  plus copy-paste templates for Express routes, Sequelize models, Tool plugins,
  and WebSocket handlers

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Update project structure trees to reflect current filesystem

Add missing directories: tests/, .knowledge/, .specify/, .github/, views/,
private/, spice/, build/, examples/, scripts/middleware.js.
Both abbreviated (AGENTS.md) and detailed (.knowledge/code-patterns.md) trees
now match the actual repo layout.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.30-20260501 [version bump]

* Add Layers_.js to project structure (key singleton L_)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix project structure: correct API layout, frontend modules, code templates

API/Backend/ uses feature-domain modules (Draw/, Users/, Config/, etc.)
with setup.js + routes/ + models/ per feature — not APIs/ or Databases/.
Frontend essence/ has Components/, Helpers/, LandingPage/, mmgisAPI/,
services/ — not Ancillary/. Basics/ includes all singletons (Globe_,
Formulae_, ToolController_, Viewer_, ComponentController_, Test_).
Code templates updated to match actual patterns (setup.js, module.exports).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove test infrastructure (Test_ module, testModules, DrawTool.test)

- Delete src/essence/Basics/Test_/ directory
- Delete src/essence/Tools/Draw/DrawTool.test.js
- Remove Test_ import and Shift+T keydown handler from essence.js
- Remove tests key from Draw tool config.json
- Remove testModules generation logic from API/updateTools.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.31-20260501 [version bump]

* style: move Cesium link button to top-right and match Leaflet zoom button styling

- Change control container from top-left to top-right positioning
- Update button size from 26px to 30px to match Leaflet zoom controls
- Use CSS variables (--color-a, --color-f, --color-mmgis) instead of hardcoded colors
- Add border-radius and box-shadow matching Leaflet control appearance
- Update hover/inactive states to use themed colors

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: anchor map logo to viewport instead of Leaflet map panel

- Change MapLogo parent from .leaflet-bottom.leaflet-right to #main-container
- Switch CSS position from absolute to fixed for viewport anchoring
- Add explicit bottom-offset positioning in BottomElementPositioner (desktop)
- Add explicit bottom-offset positioning in BottomElementPositioner (mobile)
- Logo stays at viewport right edge regardless of open side panels
- Retains smooth bottom offset transitions when bottom bar appears

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: remove references to deleted test infrastructure (Test_, DrawTool.test)

- Remove Test_/ from project structure in .knowledge/code-patterns.md
- Remove DrawTool.test.js references from specs/006 spec, plan, and tasks
- Remove Draw Tool Testing section from tasks.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.32-20260501 [version bump]

* fix: append logo to document.body to avoid filter containing block

#main-container has a CSS filter property which creates a new containing
block per the CSS spec, causing position:fixed to behave like absolute.
Appending to document.body ensures true viewport-fixed positioning.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: prevent mobile topBarTitleName text wrapping by replacing max-width with white-space: nowrap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.33-20260501 [version bump]

* chore: bump version to 5.0.0 and update changelog

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(ui): move Screenshot/Fullscreen to BottomBar, About to TopBar kebab

TopBar kebab menu now contains only Keyboard Shortcuts, Settings, and About
(About now shows on both desktop and mobile).

BottomBarReact now renders Screenshot, Fullscreen, and Copy Link buttons
(top to bottom) following the same IconButton + Tooltip pattern. The
About button has been removed from BottomBarReact.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(mobile): enforce exclusive panel toggling on mobile in TopBar

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* style: reposition LithoSphere globe controls to match Leaflet/Cesium theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(topbar): hide Viewer/Globe toggles based on configured panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): reorder buttons (Copy Link, Screenshot, Fullscreen) and unify size

Reorder the BottomBarReact buttons top-to-bottom to: Copy Link, Screenshot,
Fullscreen.

Move the 24x24 button sizing from the #topBarLink id selector in mmgis.css
into the .barButton class in BottomBarReact.module.css so all three buttons
share the same compact size as the original Copy Link button. Drop the now
redundant #topBarLink rule.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): increase padding-bottom to 12px and button margin to 3px 0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: rearrange globe controls — compass top-right circular, nav row, vertical column, panels open left

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* chore: bump version to 5.0.2-20260505 [version bump]

* style: anchor observe settings panel right:34px and float nav hover panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(theming): add 5 new themes, --color-shadow variable, and configure ThemePreview

- Add Dark Terra, Dark Nebula, Dark Lunar, Dark Supernova, Light Botanical themes
- Add --color-shadow CSS variable to every theme + :root fallback
- Replace hardcoded rgba shadow colors with var(--color-shadow) in TopBar,
  Toolbar, SeparatedTools, ToolPanel, FloatingElements, Dropdown, Modal,
  and SplitScreens
- Add Custom shadowcolor color picker in tab-ui-config and apply it via Stylize
- Add ThemePreview component (configure/src) wired through Maker.js as
  a new 'themepreview' row type so the configure UI shows a live mini
  mockup of the selected theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* fix(configure/ThemePreview): tighten top spacing and live-preview Custom theme

- Pull the preview up by 12px so the gap below the theme dropdown is tighter.
- Read the Custom color pickers (look.primarycolor / secondarycolor /
  tertiarycolor / accentcolor / shadowcolor / topbarcolor / toolbarcolor /
  mapcolor) from the configuration and overlay them on Dark Default so
  the preview reflects Custom theme edits live, matching Stylize.js's
  runtime behavior.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260505 [version bump]

* feat(themes): add Dark Heliosphere, Dark Monokai, and Light Solarized

- Dark Heliosphere: deep night purple surface with corona-orange accent.
- Dark Monokai: warm graphite surface with lime accent (Monokai-inspired).
- Light Solarized: classic solarized base3/base02 with blue accent.

Mirror added to configure/src/themes/themes.js for the ThemePreview, and
the three names appended to the Color Theme dropdown options.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(coordinates): respect time.initiallyOpen when live deep-link is set

* chore: bump version to 5.0.3-20260505 [version bump]

* refactor(theming): remove Custom theme + per-field color overrides

- Drop the 'Custom' option from the Color Theme dropdown.
- Remove all Custom Color Options (look.primarycolor, .secondarycolor,
  .tertiarycolor, .accentcolor, .bodycolor, .topbarcolor, .toolbarcolor,
  .mapcolor, .hightlightcolor, .shadowcolor) from tab-ui-config.json.
- Strip the matching DOM/CSS-variable override block from Stylize.js;
  Stylize now just applies the selected preset theme (and the page logo).
- Drop the empty bodycolor/topbarcolor/toolbarcolor/mapcolor/shadowcolor
  defaults from API/templates/config_template.js.
- Simplify ThemePreview to render the selected preset directly — no
  Custom branch, no overlay logic.

Preset themes cover all the looks we want and keep the configure surface
much smaller.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(time-ui): round corners on TimeUI shell, action wrappers, mode dropdown

- #timeUI: 10px border-radius on the outer time control bar.
- #mmgisTimeUIActionsLeft / #mmgisTimeUIActionsRight: 10px border-radius
  so the action clusters sit as rounded chips.
- #mmgisTimeUIActionsRight > div (excluding #mmgisTimeUIPresent): 10px
  border-radius on each action button so they match the wrapper.
- #mmgisTimeUIModeDropdown: 40px height + 10px border-radius to align
  with the rest of the bar; clear the dropy default border-color so the
  rounded edge isn't outlined.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260505 [version bump]

* feat(configure): mark light themes as (experimental) in dropdown label

Light themes still have outstanding contrast issues, so flag them in the
Color Theme dropdown without changing the saved value.

- Maker dropdown now accepts options as either a plain string (current
  behavior) or { value, label } so the rendered label can differ from
  the persisted value.
- tab-ui-config switches the six light themes to { value, label } form
  with '(experimental)' appended to the label only. Existing mission
  configs that already saved 'Light Default' etc. continue to match.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix timeUI border radius

* fix(mobile): rescue #timeUI before tool make() destroys it

Clicking Layers -> Time -> Layers -> Time on mobile caused the bottom
panel to render LayersTool content with TimeUI height. The #timeUI DOM
element was destroyed when LayersTool.make() called $('#tools').empty(),
before the async React useEffect in MobileTimeUIToggle could rescue it
to its staging container.

- ToolController_.makeTool: synchronously move #timeUI from #tools back
  to #timeUIMobileStaging (and reset TimeUI store flags) on mobile,
  before invoking the new tool's make().
- MobileTimeUIToggle.handleClick: defensive fallback that re-initializes
  TimeUI if #timeUI no longer exists when the toggle is activated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): move re-initialized #timeUI from staging into #tools

TimeUI.init() on mobile appends the new #timeUI to the hidden
#timeUIMobileStaging container, so the fallback branch must also move
it into #tools — otherwise the user sees an empty tool panel after
the destroyed-element recovery path.

Caught by Devin Review.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): preserve #timeUI when Coordinates tool empties #tools

On mobile, opening or closing the Coordinates tool runs
$('#tools').empty() inside interfaceWithMMWebGIS / separateFromMMWebGIS.
After the previous PR commits, clicking Coordinates -> Time still left
the bottom panel empty because:

- Coordinates.make() empties #tools while #timeUI is in staging (fine
  on its own), but the Coordinates teardown that fires after the user
  switches to the Time toggle (via MobileCoordButton's useEffect on
  activeToolName change) calls Coordinates.destroy() ->
  separateFromMMWebGIS(), which empties #tools wholesale and destroys
  the freshly-placed #timeUI.

Add a rescueMobileTimeUI() helper that moves #timeUI from #tools back
to #timeUIMobileStaging before each tools.empty() call in Coordinates,
mirroring the rescue already done in ToolController_.makeTool().

Coordinates -> Time now correctly shows the TimeUI.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): harden TimeUI fallback recovery (call fina(), de-dupe popovers)

Devin Review correctly flagged that the safety-net path in
MobileTimeUIToggle.handleClick was producing a partially-broken TimeUI
when it fired:

- TimeUI.init() unconditionally appends a new #timeUIPlayPopover_global
  to <body>, so a second init() left two elements with the same id.
- TimeUI.init() alone does not wire up date pickers or per-button click
  handlers — that's TimeUI.fina()'s job. Without fina(), the recovered
  TimeUI rendered visually but Play / Previous / Next / Fit / Follow /
  Present / Expand were all dead.

Before re-initializing, remove the stale #timeUIPlayPopover_global and
#timeUIQuickSelectPopover_global divs to avoid duplicate ids. After the
new #timeUI is moved into #tools, call TimeUI.fina() to populate the
date pickers, attach the button click handlers, build the histogram,
and populate the expanded mobile rows.

Some delegated body/document handlers in attachEvents() will still be
duplicated on this path; that is acceptable for a degraded recovery
that should never run in practice now that the primary rescues in
ToolController_.makeTool() and Coordinates.js cover all known paths.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260505 [version bump]

* fix(mobile): Coordinates teardown only removes its own DOM

The previous Coordinates fix was racing with itself: after the Time
toggle synchronously moved #timeUI into #tools, MobileCoordButton's
useEffect (triggered by the activeToolName change) ran on the next
React tick and called L_.Coordinates.destroy(). That called
separateFromMMWebGIS(), whose rescue moved #timeUI right back into the
hidden staging div before tools.empty() — so the bottom panel ended up
empty even though the time toggle was 'active'.

Make separateFromMMWebGIS selective: only remove the
Coordinates-specific DOM (#coordUIHeader and #CoordinatesDiv) instead
of wiping all of #tools. Any other content already in #tools (e.g.
#timeUI placed there by the Time toggle) is left alone.

interfaceWithMMWebGIS still keeps the rescue + tools.empty() pattern
on the open path so Coordinates always starts from a clean panel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Bump DrawTool Temporal Drawings upward

* chore: bump version to 5.0.6-20260505 [version bump]

* chore: reset version to 5.0.0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test(e2e): fix 9 pre-existing failures (test-only changes)

- mmgis-api.spec.js: add form-fill login under AUTH=local; serialize
  describe to avoid concurrent-login race in the session store
- coordinates.spec.js: TimeUI toggle was moved from the coordinates bar
  to the Settings modal; navigate via topbar kebab menu and assert the
  checkbox is rendered
- widgets.spec.js: target .leaflet-control-zoom-in/-out specifically;
  the bare .leaflet-control-zoom class is also used by the home/reset
  control, so the original assertion was always false
- sites.spec.js: scope panel selector to #toolPanel; both the toolbar
  icon and the panel container share id="SitesTool"

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* Revert "chore: bump version to 5.0.1-20260505 [version bump]"

This reverts commit 4880204c1163be5d1d7fa96d14a0ed018c6f586c.

* fix: prevent filter operator dropdown clipping in Layers panel

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260507 [version bump]

* revert: keep dropy openUp:true for operator dropdowns

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit d67c369ed437e47d658ae051348d377978dc48ed.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 29565ed829a55e9c241a789c9a3901d11cb5ca67.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 50e357604ebe9378564619b34c508b63cfb62c1d.

* chore: bump version to 5.0.1-20260507 [version bump]

* chore: bump version to 5.0.2-20260511 [version bump]

* fix: render Globe panel immediately on first open without window resize

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260511 [version bump]

* feat: add theme borders to panels and gradient backgrounds to splitters

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: bump split shadow gradient opacity to 0.4

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal 3-col grid + smaller leaflet zoom button gap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: prevent hotkey label/value wrapping (ellipsis instead)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal single column, no wrap, no truncation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: hotkeys modal dividers, invert title/subtitle colors, rename title, margin above subtitles

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: move splitter gradient to themed CSS class, restore hover feedback

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* style: hotkeys section titles use --color-h (matches rest of app)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* fix: guard Globe_.init() inside rAF to prevent double instantiation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.6-20260512 [version bump]

* feat(plugins): per-plugin deps, lazy tool loading, validation, shared discovery

Phase 3 — Plugin config validation + override warnings:
- New API/pluginValidation.js with validatePluginConfig() for tool, component,
  and backend manifests. Validates required fields (name, paths), object/string
  shape of paths, dependencies block (npm/python.pip/python.conda), and warns
  on unknown top-level fields.
- updateTools()/updateComponents() now skip invalid plugins and emit override
  warnings (matching what components already logged for tools).

Phase 2 — Shared discoverPlugins() utility:
- New API/pluginDiscovery.js consolidates the duplicated scanning logic from
  updateTools(), updateComponents(), and getBackendSetups(). Supports exact-
  name and substring container patterns, JSON/require/no-op loaders, and skips
  dot/underscore-prefixed dirs.
- updateTools.js and setups.js refactored on top of the shared helper.

Phase 1 — Per-plugin dependency declaration + build-time aggregation:
- Plugin config.json may now declare a 'dependencies' block (npm + python.pip +
  python.conda). validatePluginConfig() also validates this shape.
- New scripts/resolve-plugin-deps.js scans every tool/component/backend plugin
  and writes plugin-package.json, plugin-python-requirements.txt, and
  plugin-conda-deps.txt. Detects version conflicts and fails loudly.
- scripts/build.js calls resolvePluginDeps() before updateTools().
- Dockerfile installs the aggregated plugin npm and pip deps after the root
  npm ci, using --no-save / --no-package-lock / --ignore-scripts so the root
  lockfile is untouched.
- Animation tool migrated: ffmpeg/gifshot/html2canvas now declared in its
  config.json (kept in root package.json for transitional compat).
- Generated artifacts gitignored.

Phase 4 — Lazy loading of tool bundles:
- updateTools() now emits dynamic-import arrow functions in the generated
  src/pre/tools.js with webpackChunkName hints so each tool is split into
  its own chunk (Kinds stays static because it's required synchronously).
- ToolController_ gains ensureToolLoaded(name) and getLoadedTool(name) helpers
  and makeTool is async; init/finalizeTools and the separated-tool auto-open
  flow are updated to handle lazy modules.
- Toolbar.jsx, SeparatedTools.jsx, SitesTool.js, and Layers_.js migrated to
  resolve LayersTool/etc. via the new helpers instead of poking toolModules
  directly.

Tests & docs:
- tests/fixtures/test-plugin-tools/{TestPlugin,InvalidPlugin,OverridePlugin}
  + tests/helpers/plugin-helpers.js with install/uninstall helpers.
- New unit specs: pluginValidation, pluginDiscovery, updateTools,
  resolvePluginDeps, toolLazyLoading (57 tests, all passing).
- CONTRIBUTING.md and docs/pages/Contributing/Contributing.md updated with
  schema, override behaviour, dependency declaration, build-time aggregation,
  conflict detection, and Docker integration.

* chore: bump version to 5.0.7-20260512 [version bump]

* fix: make Globe_.init() idempotent against multi-init

Globe_.init() previously constructed a fresh GlobeRenderer on every call,
which after #71 could happen multiple times for a single toggle (uiStore
setTimeout + TopBar rAF). Each extra construction appends another
.cesium-widget / _lithosphere_scene to #globe and leaves event handlers
wired to dereferenced renderer state, which has been observed to break
LithoSphere globe control buttons on configurations where the globe panel
starts closed at boot.

Add a top-of-init() guard that bails out and calls invalidateSize() when
a renderer already exists. Single small, surgical change; no behavior
change for the !L_.hasGlobe mock-swap path or for first-time construction.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.7-20260512 [version bump]

* test(plugins): generate src/pre/tools.js on demand in toolLazyLoading spec

The Playwright unit-tests CI step runs before `npm run build` so the
gitignored `src/pre/tools.js` artifact does not yet exist on disk.
Add a beforeAll hook that invokes `updateTools()` to regenerate it
when missing, keeping the spec self-contained on both CI and dev
machines that already built locally.

* fix(tools): defensive getTool() + preload flag for cross-referenced tools

Devin Review flagged a behavioural regression introduced by Phase 4:
`ToolController_.getTool(name)` previously always returned a method-
callable object (real module or `{ use(){} }` stub) because every tool
was statically imported. After Phase 4, unresolved lazy loaders are
`() => import(...)` functions, so callers like `Map_.getTool('InfoTool').use(...)`,
`mmgisAPI.getTool('DrawTool').filesOn`, and `LegendTool` calling
`LayersTool.populateCogScale` would crash with TypeError until the
target tool was opened.

Two fixes:

1. **Defensive getTool()**: Returns the legacy fallback stub when the
   tool module is still a lazy-loader function, and fires off
   `ensureToolLoaded(name)` in the background so subsequent calls see
   the resolved module. Prevents all crashes immediately.

2. **`preload: true` config flag**: Tools reached synchronously from
   other code paths (Info, Draw, Layers, Chemistry) now declare
   `"preload": true` in their `config.json`. `ToolController_.init()`
   calls `preloadEagerTools()` which fires `ensureToolLoaded` for
   every such tool right after toolbar setup — the chunks download
   in parallel with the rest of the page becoming interactive, so by
   the time a user clicks a feature the InfoTool module is already
   resolved.

`validatePluginConfig` now accepts `preload` as a known tool field;
CONTRIBUTING.md and docs/pages/Contributing/Contributing.md updated to
document when to set it. Added a unit test covering the defensive
getTool behaviour and the `preload` propagation through
`toolConfigs`.

* chore: bump version to 5.0.8-20260512 [version bump]

* revert(plugins): remove Phase 4 lazy tool loading and preload mechanism

Phase 4 lazy emission caused cross-tool consumers (Map_ feature-click,
mmgisAPI, LegendTool) to receive raw '() => import(...)' arrows from
ToolController_.getTool(), breaking InfoTool open. Reverting to the
pre-Phase-4 behavior of static tool imports.

- API/updateTools.js: generated src/pre/tools.js now emits
  'import FooTool from ...' for every tool (Kinds stays static too).
- ToolController_.js: getTool/makeTool back to sync; ensureToolLoaded,
  getLoadedTool, preloadEagerTools deleted; separated-tool auto-open
  flow simplified to direct sync calls.
- Toolbar.jsx, SeparatedTools.jsx, Layers_.js: revert async/lazy
  patterns to sync ToolController_.toolModules[name] access.
- API/pluginValidation.js: drop 'preload' from KNOWN_FIELDS.
- src/essence/Tools/{Info,Draw,Layers,Chemistry}/config.json: drop
  'preload: true'.
- CONTRIBUTING.md + docs: remove preload documentation.
- tests/unit/toolLazyLoading.spec.js: rewrite to verify static
  imports instead of lazy loaders.

Also: log standard backends at startup (parity with plugin backends
and with tools/components), so all backends now produce
'info  Loaded backend: <name> from <container>' at boot.

Phases 1-3 (per-plugin dependency aggregation, shared discoverPlugins,
config validation + override warnings) are unaffected.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(logger): new 'loaded' level (purple bg) for tool/component/backend startup

Previously the 'Loaded tool/component/backend: X from Y' lines used
the generic blue 'info' tag. They now use a dedicated 'loaded' level
rendered with a purple (#a855f7) background, so plugin discovery
output is visually distinct from other info messages.

- API/logger.js: add 'loaded' case to the dev-mode switch (white text
  on purple bg) and suppress the redundant 'Caller:' echo for it
  (matches how 'info' and 'success' are handled).
- API/updateTools.js: registerPlugin now logs at level 'loaded'.
  Drops the redundant 'Loaded ' prefix since the level tag now reads
  'loaded'.
- API/setups.js: standard and plugin backend startup logs use the
  new level, same drop of the 'Loaded ' prefix.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(logs): 'Plugging in Tools/Components/Backends...' headings

Rename the cyan banner messages in scripts/build.js and scripts/server.js
from 'Updating Tools...' / 'Updating Components...' to 'Plugging in
Tools...' / 'Plugging in Components...' so the headings match the
plugin terminology used everywhere else (plugin-package.json,
discoverPlugins, etc.).

Also add a matching 'Plugging in Backends...' banner before
setups.getBackendSetups() in scripts/server.js so backends get an
equivalent title block to tools and components.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(logs): cyan banner lines lead with a blank line instead of trailing one

Move the \n from the end to the beginning of every cyan banner in
scripts/build.js and scripts/server.js (Resolving Plugin Dependencies,
Plugging in Tools/Components/Backends, Validating Environment
Variables, Starting websocket, Starting the development server) so
that the blank line visually separates each section above its title
rather than below it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(plugins): postinstall hook auto-installs plugin npm deps

Plain `npm install` (or `npm ci`) on a fresh clone now resolves and
installs every plugin's declared npm dependencies automatically, so
new developers don't need to remember a second command.

- scripts/install-plugin-deps.js (new): reads plugin-package.json,
  filters out deps already declared in root package.json with the
  same version specifier (no-op for the Animation transitional case),
  installs the remainder with `npm install --no-save --no-package-lock
  --ignore-scripts <pkg@ver> ...`. `--no-package-lock` keeps the
  root lockfile clean; `--ignore-scripts` prevents the inner install
  from re-entering postinstall and matches the Dockerfile.
- package.json: postinstall guards against the Dockerfile's
  package.json-only layer (`scripts/` not copied yet) by checking
  for the two script files via `node -e` before invoking them.
  Adds a `plugins:install` npm script for on-demand runs.
- CONTRIBUTING.md + docs/pages/Contributing/Contributing.md: replace
  the manual-install paragraph with a note about the postinstall
  hook and the filtering behavior.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs(plugins): manual Python install step for plugin pip/conda deps

Document that the npm postinstall hook only handles plugin npm deps —
plugin pip/conda deps must be installed manually after creating the
Python environment, since there's no portable way to detect which
interpreter or environment to target from a Node script.

- CONTRIBUTING.md: added a 'For local development ... Python' block
  with the explicit `node scripts/resolve-plugin-deps.js` +
  `micromamba run -n mmgis pip install -r plugin-python-requirements.txt`
  + optional conda install commands.
- docs/pages/Contributing/Contributing.md: matching short blurb in
  the user-facing docs.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs(install): plugin Python install step in Installation.md

Add a numbered step in docs/pages/Setup/Installation/Installation.md's
Setup sequence (right after `micromamba activate mmgis`) with the
`pip install -r plugin-python-requirements.txt` and optional
`micromamba install --file plugin-conda-deps.txt` commands, so
non-Docker installs have the step in their main flow rather than
buried in CONTRIBUTING.md.

Also adds a pointer from the Python Environment section earlier in
the same file (after the env-create + activate steps) back to the
numbered Setup step.

CONTRIBUTING.md and docs/pages/Contributing/Contributing.md are
slimmed: instead of duplicating the install commands, both now link
to the Installation page (this matches the user request — the install
commands live in installation docs, not contribution docs).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: serialize concurrent layer reloads and stop mutating layer.url

Concurrent mmgisAPI.reloadLayer() calls for the same layer were
silently dropped by the _layersBeingMade guard, and reloadLayer
mutated layer.url in-place during async work — causing a race where
a second reload would capture the resolved URL as its 'original' and
permanently corrupt the {starttime}/{endtime} template placeholders.

Changes:
- TimeControl.reloadLayer: compute resolvedUrl into a local variable
  instead of mutating layer.url. Pass resolvedUrl through to
  Map_.refreshLayer.
- Map_.refreshLayer: accept resolvedUrl; temporarily swap layer.url
  inside a try/finally for the makeLayer call so the fetched URL is
  the resolved one and the placeholder template is always restored.
- Map_.refreshLayer: when a reload is already in flight for the
  same layer, queue the request (coalesced by name) instead of
  dropping it with a 'Cannot make layer' warning.
- Map_.makeLayer: after releasing the lock, drain any queued reload
  for this layer via setTimeout 0.
- TimeControl.reloadTimeLayers: become async and await Promise.all
  of every per-layer reload; remove the setTimeout(500) workaround
  around active-feature restoration and follow-pan logic.
- mmgisAPI.reloadLayers: new batch API that reloads multiple
  time-enabled layers concurrently and returns a Promise<boolean[]>.
- Tests: new tests/e2e/map/concurrent-layer-reload.spec.js covers
  the seven scenarios from the plan (single reload, URL preservation,
  multi-layer concurrent reload, rapid same-layer reload, reloadLayers
  API surface). tests/e2e/api/mmgis-api.spec.js gains a surface
  check that reloadLayer/reloadLayers are exposed.
- Docs: Main.md documents the new reloadLayers entry.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.8-20260513 [version bump]

* fix(TimeControl): use Promise.allSettled in reloadTimeLayers

Promise.all short-circuits on first rejection, which would skip the
active-feature restoration and follow-pan logic if any single layer
reload threw (network error, malformed config, etc.). The old
setTimeout(500) approach ran those steps unconditionally; switching
to Promise.allSettled preserves that robustness.

Addresses Devin Review feedback on PR #78.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mmgisAPI): use Promise.allSettled in reloadLayers batch API

Mirrors the reloadTimeLayers fix: a single failing TimeControl.reloadLayer
call (e.g. unknown layer name throws inside asLayerUUID, network error,
malformed config) no longer rejects the whole batch. The returned array
preserves order and reports failed entries as false instead, matching
the documented Promise<boolean[]> contract.

Addresses second Devin Review finding on PR #78.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: note that reloadTimeLayers is now async (Promise<string[]>)

The implementation changed from sync to async in commit a096cfe9 (the
function now uses await + Promise.allSettled internally to coordinate
per-layer reloads), but the public API JSDoc and Main.md still
documented the old synchronous return type. External consumers using
the old synchronous return value would get a Promise instead of an
array.

Updates JSDoc on mmgisAPI.reloadTimeLayers to declare
Promise<string[]>, and rewrites the Main.md example to use 'await'.
Also fixes the previous example, which had a syntactically-malformed
trailing tuple-style index.

Addresses third Devin Review finding on PR #78.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: split time-related e2e specs into tests/e2e/time/

Moves the two time-feature specs out of tests/e2e/map/ so the map suite
stays focused on map-UI behavior and the time/time-enabled-layer suite
can be run (and reasoned about) on its own:

- tests/e2e/map/time-control.spec.js
    -> tests/e2e/time/time-control.spec.js
- tests/e2e/map/concurrent-layer-reload.spec.js
    -> tests/e2e/time/concurrent-layer-reload.spec.js

Relative imports (../../helpers, ../../pages, ../../fixtures) are
unchanged because the new directory is the same depth. Playwright
picks the files up automatically via testDir './tests' +
testMatch '**/*.spec.js'.

Adds a matching 'npm run test:e2e:time' script and documents the new
suite in tests/README.md alongside the existing per-suite scripts.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: cap local Playwright workers at 4

The previous `workers: process.env.CI ? 1 : undefined` resolved to
Playwright's default (~half the CPU cores). On higher-core machines
(e.g. 16 cores -> 8 workers) the dev server gets overloaded and the
suite actually runs slower. CI behavior is unchanged (1 worker).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: thread resolvedUrl through makeLayer/captureVector instead of mutating layer.url

The previous fix in Map_.refreshLayer temporarily swapped
`layerObj.url = resolvedUrl` during the `await makeLayer` call and
restored it in a finally block. Since `layerObj` is the shared
`L_.layers.data[name]` object, any concurrent code reading
`layer.url` during that async window could observe the resolved URL
instead of the template. Most importantly, a second
`TimeControl.reloadLayer()` call would then capture the resolved
URL as its 'template' and corrupt the placeholders for every
subsequent reload.

Surfaced by tests/e2e/time/concurrent-layer-reload.spec.js Test 5,
which after Promise.all of two reloads observed
`layer.url ==='geodatasets:...?from=...&to=...'` instead of the
expected `{starttime}/{endtime}` template.

Fix: thread `resolvedUrl` as an explicit parameter through
`Map_.refreshLayer` -> `makeLayer` -> `makeVectorLayer` ->
`captureVector` (via options.resolvedUrl). `captureVector` uses
`options.resolvedUrl` when provided and skips the
`{starttime}`/`{endtime}`/`{customtime.*}` regex replacement
(which TimeControl.reloadLayer already performed). `layer.url` is
NEVER mutated for the duration of the async operation, so the
template is preserved across overlapping reloads.

This also fully resolves the Devin Review #4 finding which flagged
the temporary URL swap as reintroducing the same race the PR was
meant to fix.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: also replace {time} placeholder when computing resolvedUrl

In the previous fix, captureVector skips its time-replacement block when
the caller supplies options.resolvedUrl (because TimeControl.reloadLayer
already performed those replacements). However TimeControl.reloadLayer
was only replacing {starttime}/{endtime}/{customtime.*} on resolvedUrl,
not {time} — which captureVector previously handled at
LayerCapturer.js:97 by mapping {time} -> endTime. This caused vector
layers using the documented {time} placeholder (see
docs/pages/Configure/Layers/Tile/Tile.md:90 and
docs/pages/APIs/JavaScript/Main/Main.md:482) to fetch URLs containing
the literal text '{time}' on time-triggered reloads.

Mirror the existing captureVector behavior: replace {time} with the
formatted end-time value alongside {starttime}/{endtime}, before the
resolved URL is threaded through Map_.refreshLayer -> makeLayer ->
captureVector.

Addresses Devin Review finding on commit fcba8101.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: always run time-placeholder replacement in captureVector (idempotent)

The previous fix gated captureVector's time-placeholder replacement
block on `!hasResolvedUrl`, on the assumption that any caller passing
options.resolvedUrl had already done the replacement.

That assumption only holds for time.type === 'global' / 'requery' /
forceRequery. For other time types that still flow through
Map_.refreshLayer into captureVector (most importantly
time.type === 'local' with endProp == null per TimeControl.js:276-287),
TimeControl.reloadLayer's resolved-URL replacement block at lines 249-273
is skipped, so the resolvedUrl arrives at captureVector still containing
literal {starttime}/{endtime}/{time} placeholders. The fetch then goes
out with unreplaced placeholders.

Fix: drop the !hasResolvedUrl guard and always run the replacement,
reading the source from `layerUrl` (which is already either
options.resolvedUrl or layerObj.url per the choice above). The
.replace(/{starttime}/g, ...) chain is idempotent on URLs that have
already been resolved — the regexes simply don't match — so the
correct path is restored without re-introducing the mutate-in-place
bug fcba8101 fixed.

Addresses Devin Review finding on commit ddb90dbb.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: remove GIBS MODIS time tile test (always skips on external dependency)

The test at tests/e2e/time/time-control.spec.js depended on
gibs.earthdata.nasa.gov being reachable from the test environment and
served only as a placeholder — it skips every run since the test
infrastructure does not have external network access by policy. It
provides no signal in CI or locally, so removing it reduces noise in
the suite output without losing coverage.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: add coverage for {time}, local-endProp-null, stress, and feature-presence cases

Four new e2e tests in tests/e2e/time/concurrent-layer-reload.spec.js,
each targeting a gap the original suite missed:

Test 8 — {time} placeholder preservation. Mirrors Test 2/5 but uses
`{time}` instead of `{starttime}/{endtime}`. Catches the Devin
Review #5 regression where captureVector's gating on !hasResolvedUrl
silently dropped the {time} -> endTime replacement (the literal
'{time}' would have ended up in the fetch URL).

Test 9 — local + endProp==null path. Sets layer.time.type='local'
and layer.time.endProp=null to force the TimeControl.reloadLayer
branch (TimeControl.js:276-287) that bypasses the resolved-URL
placeholder block and falls through to the else clause. Inspects
outgoing /geodatasets/* requests via page.on('request', ...) and
asserts no literal {starttime}/{endtime}/{time} remain. Catches the
Devin Review #6 regression: when this branch hit captureVector with
hasResolvedUrl=true, the !hasResolvedUrl gate previously short-
circuited the only remaining replacement site.

Test 10 — 20-reload stress burst. Extends Test 4's two-reload check
to 20 concurrent reloadLayer() calls, capturing 'Cannot make layer'
warnings to verify the queue coalesces requests instead of silently
dropping them. Also re-asserts layer.url template integrity post-
burst.

Test 11 — Feature-presence after concurrent reload. Captures
L_.layers.layer[key].getLayers().length before and after a 5-reload
burst. Asserts the count is still > 0 afterwards — the user-visible
'gaps where dynamically-appearing data doesn't show up' symptom from
the original bug report.

All four tests skip gracefully when their fixture layer is absent or
the dataset returns no rows, to avoid spurious failures across
mission configurations.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: add 5 edge-case tests probing the blast radius of the URL fix

Five additional tests in tests/e2e/time/concurrent-layer-reload.spec.js
covering paths adjacent to the URL-mutation fix that were not exercised
by tests 1-11. Each was chosen because the production code on that path
changed (or now has a different contract) and a regression would not be
caught by the original race-condition tests.

Test 12 — {customtime.N} placeholder preservation. TimeControl.reloadLayer's
customtime replacement loop was migrated from `layer.url = ...` to
`resolvedUrl = ...`. Seeds TimeControl.customTimes.times so the loop
actually runs, then asserts the {customtime.0} placeholder remains
literally on layer.url after reload.

Test 13 — mmgisAPI.reloadTimeLayers() returns a Promise. This is the
backward-incompatible behavior change documented in
docs/pages/APIs/JavaScript/Main/Main.md (previously synchronous).
Asserts the returned value is a thenable that resolves to an array,
pinning the new contract so it does not silently regress.

Test 14 — mmgisAPI.reloadLayers handles unknown layer names. The
Promise.allSettled change requires that a failing per-layer reload
surfaces as `false` at the same array position as its input name,
without throwing. Mixes a valid name + an unknown name + another
valid name to verify the order and the boolean mapping.

Test 15 — Reloading a time-DISABLED vector layer leaves layer.url
unchanged. Discovers a candidate layer from L_.layers.data at runtime
(skips if none exist), reloads it, and asserts the URL is byte-equal
afterwards. Catches any accidental URL mutation introduced for the
non-time code path.

Test 16 — mmgisAPI.reloadLayers handles empty array, null, undefined,
and string inputs without throwing — verifying the Array.isArray guard
at mmgisAPI.js:618. Returns `[]` in all cases.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(makeLayer): wrap dispatch in try/finally so lock + queue drain always run

Address Devin Review finding: a thrown exception from any layer-type
builder (makeVectorLayer, makeVelocityLayer, etc.) inside makeLayer's
switch statement previously left lockRegistry[layerName] set to true
and skipped the queue drain entirely, since the release statement and
queue-drain block both lived AFTER the awaited dispatch.

Effect of the bug: any subsequent refreshLayer call for that layer
would queue against a permanently-locked entry that never drains.
The new queue mechanism inherits this pre-existing issue and makes
the failure mode worse — silent accumulation in _layerReloadQueue
instead of a visible 'Cannot make layer' warning.

Additional concern: the outer 'new Promise(async (resolve, reject) =>
{...})' is the async-executor anti-pattern. A throw inside the async
executor escapes to the unhandled-rejection handler instead of
rejecting the outer Promise — so the caller's 'await makeLayer(...)'
would hang indefinitely, compounding the lock-leak symptom.

Fix:
  - Wrap the type-dispatch switch + Filtering.updateGeoJSON/
    triggerFilter calls in a try/catch/finally.
  - catch logs the error and tracks success via 'madeSuccessfully'.
  - finally runs unconditionally: lockRegistry[layerName] = false,
    drain L_._layerReloadQueue[layerObj.name] if present, then
    resolve(madeSuccessfully). This ensures the lock release and
    queue drain happen regardless of whether the inner builder
    threw or completed normally.

Test (concurrent-layer-reload.spec.js):
  Added probe-style test '_layersBeingMade lock is released after
  single and concurrent reloads' that asserts the lock invariant:
    1. After mmgisAPI.reloadLayer() resolves +
       a 100ms drain window, _layersBeingMade[key] is false.
    2. After 5 concurrent mmgisAPI.reloadLayer() calls resolve +
       a 1000ms drain window, _layersBeingMade[key] is false.
    3. _layerReloadQueue is empty afterwards (otherwise a future
       reload would mistakenly trigger an immediate drain instead
       of doing its own work).

  This is a positive-invariant test — it catches accidental lock
  retention even without force-triggering exceptions, which would
  require monkey-patching webpack-internal module references that
  aren't exposed on window.

Per user direction, NOT addressing Devin Review's separate finding
about the reloadTimeLayers sync->async breaking change at this
time (no version bump requested).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.9-20260513 [version bump]

* fix(OperationsClock): bump z-index above bottomFloatingBar so clock stays visible

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(plugin-deps): respect override semantics when aggregating dependencies

When a plugin tool/component/backend overrides a standard one by reusing
the same directory name, only the override's deps should contribute to
the aggregated plugin manifests. Previously, gatherDependencies()
concatenated standard + plugin entries and fed both to mergeNpm/
mergePython, which could spuriously flag the same package as
conflicting between the standard and override versions.

Extract winnersByName() (mirroring API/updateTools.js +
API/setups.js override behavior) and use it for all three plugin
kinds. Add unit tests covering the override case and the
spurious-conflict regression.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(Dockerfile): re-install plugin npm deps in runtime stage

Plugin deps installed in the builder via `npm install --no-save
--no-package-lock` aren't recorded in package.json/package-lock.json,
so the runtime stage's `npm ci --only=production` would lose them.
Frontend deps are bundled by webpack into ./build so they're fine, but
backend plugins that `require()` their declared npm dependencies at
runtime would crash with 'Cannot find module'.

Copy plugin-package.json from the builder and re-run the same
conditional install in the runtime stage so backend plugin deps land
in the runtime image's node_modules. `--ignore-scripts` prevents the
inner install from re-entering the root postinstall hook.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(OperationsClock): lift to 58px bottom when TimeUI is open

Add #operationsClock to BottomElementPositioner's reactive positioning
so it shifts to bottom:58px when timeUIActive is true (TimeUI dock
visible) and back to bottom:40px when closed. Avoids overlap with
the bottom floating bar without adding new state to OperationsClock
itself. Mobile path is unchanged — OperationsClock.setupMobilePositioning
manages mobile positioning separately.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(OperationsClock): always sit at bottom:58px

Revert the dynamic positioning in BottomElementPositioner and just
hardcode bottom:58px in OperationsClock.css. Simpler, no cross-cutting
state dependency.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add Lunar South Pole reference mission variant (IAU2000:30120)

- Add REFERENCE_MISSION_VARIANTS registry and resolveVariantBlueprintPath
  helper to missionTemplates.js for dynamic variant resolution
- Update configs.js to accept referenceMissionVariant parameter and
  validate against the registry
- Add variant dropdown to NewMissionModal UI when Reference Mission
  checkbox is enabled
- Create blueprint directory with south polar stereographic config
  (IAU2000:30120, +proj=stere +lat_0=-90, bounds ±1095700/1095600)
- Add unit tests for variant registry, blueprint path resolution, and
  Lunar-SouthPole projection assertions (15 tests)
- Add E2E smoke tests for Lunar-SouthPole mission variant

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(api): pass through reloadLayer flags in reloadLayers

Forward evenIfOff, evenIfControlled, forceRequery, and
skipOrderedBringToFront parameters to TimeControl.reloadLayer()
for each layer in the batch. Fully backward-compatible — existing
callers that pass only layerNames get undefined for all flags.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.10-20260518 [version bump]

* chore: bump version to 5.0.10-20260518 [version bump]

* docs(api): add forceRequery & skipOrderedBringToFront to reloadLayer/reloadLayers docs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add SPole basemap to lunar ref mission

* chore: bump version to 5.0.11-20260518 [version bump]

* chore: remove unused blueprints/Missions/Test directory

The Test blueprint is no longer needed — the Reference-Mission and
Reference-Mission-Lunar-SouthPole blueprints serve as the basis for
demo, development, and testing.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: use setupReferenceMission string value as variant key fallback

Address Devin Review feedback: when setupReferenceMission is a non-empty
string (e.g. 'Lunar-SouthPole'), use it as the variant key if
referenceMissionVariant is not provided. Also guard against empty string
triggering reference mission creation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(measure): rename config DEM field from 'dem' to 'url' and handle HTTP URLs

- Rename 'field': 'dem' → 'url' and 'name': 'DEM Path' → 'DEM URL' in
  src/essence/Tools/Measure/config.json (layer-specific DEM objectarray)
- Rename same in configure/src/metaconfigs/layer-tile-config.json
- Add http:// and https:// prefix handling in makeProfile() so external
  URLs are not mangled with the mission path prefix

The top-level variables.dem field (primary DEM) is unchanged.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.11-20260519 [version bump]

* revert: remove http/https URL prefix handling in makeProfile()

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(measure): rename 'dem' to 'url' in Reference Mission layerDems blueprint

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(measure): accept both 'url' and 'dem' fields in layerDems for backward compatibility

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.12-20260519 [version bump]

* feat: add SPole_100m basemap layer to Lunar South Pole blueprint config

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: show Save to Base Blueprint button for all reference mission variants

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Update lunar ref mission

* style: add text-align right to mission list buttons in configure sidebar

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ui): SegmentTool background, clipboard API, context menu test selectors

-…
…s removal (#987)

* chore: remove separated tools offset logic from Globe_.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: skip _makeHistogram on mobile (no timeline slider, timestamps unset)

_makeHistogram renders inside the timeline slider which doesn't exist
on mobile. Without it, _timelineStartTimestamp is NaN, causing
'Invalid time value' RangeError at toISOString().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI — populate expanded rows, fix Invalid date, fix panel height

- TimeUI.js attachEvents: use _initialStart/_initialEnd on mobile (same
  as desktop) instead of L_.TimeControl_ which isn't set yet at init time.
  Fixes 'Invalid date' in start/end time inputs.
- TimeUI.js fina: set expanded=true on mobile and call _populateExpandedRows()
  so year/month/day/hour rows actually render. Removed position:absolute and
  pointer-events:none overrides.
- Toolbar.jsx: set tool panel height to 217px (TimeUI.height) instead of
  45% viewport — matches actual TimeUI content height.
- UserInterfaceMobile_.css: expanded content flows naturally (position:relative),
  hide start time inputs, allow overflow scroll, flex-wrap topbar.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI justify-content center, restore toolbar border-bottom

- Add justify-content: center to #mmgisTimeUIMain on mobile
- Remove border-bottom: none override so toolbar keeps its default border

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile TimeUI overflow hidden, scalebar/compass fixed at 40px offset

- #timeUI overflow-y: hidden (was auto, causing 2px scroll)
- Scalebar/compass/map controls stay at fixed 40px offset (above toolbar)
  regardless of tool panel state — no longer shift up by pxIsTools

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Implement multi-tier knowledge architecture

- Restructure AGENTS.md from 745 lines to 106 lines (Tier 1: essential context)
- Create knowledge/ directory with 30+ wiki-style documentation files (Tier 2: deep knowledge)
- Create knowledge/reference/ with 8 detailed reference files (Tier 3: lookup material)
- Move AI-GETTING-STARTED.md and AI-DEVELOPMENT.md to knowledge/
- Update all file references in .specify/templates and blueprints
- Create knowledge/README.md as the full knowledge base index
- Create knowledge/reference/README.md as reference material index

Three-tier knowledge discovery system:
  Tier 1: AGENTS.md (~106 lines) - scannable in <2 minutes
  Tier 2: knowledge/*.md - deep knowledge on architecture, tools, APIs, DB, infra
  Tier 3: knowledge/reference/*.md - coding conventions, API reference, troubleshooting

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.29-20260501 [version bump]

* fix: mobile toolbar active button style matches desktop, fix icon alignment

- All mobile toolbar buttons (ToolButton, MobileCoordButton, MobileTimeUIToggle)
  now use display:flex with align-items/justify-content center for proper
  vertical icon centering
- MobileCoordButton: changed 'active' class to 'toolButtonActive' to match
  the global CSS active style (color-mmgis + color-i background)
- Removed inline color overrides so CSS .toolButtonActive takes effect

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add Devin knowledge notes from past MMGIS sessions

Include curated lessons learned from past Devin sessions:
- CI/CD: ignore build-arm64/amd64 failures, focus on required checks
- Child sessions: no separate PRs when consolidating
- ENV triple-update rule (.env, sample.env, ENVs.md)
- Error handling: use logger with infrastructure_error for fatal startup errors
- Path traversal security: stay within /Missions, handle subpath serving
- Database initialization architecture and migration patterns
- API authentication behavior across AUTH modes
- Auto-generated MMGIS concept index

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar active button style, icon alignment, tool deactivation

- Active toolbar buttons get desktop-matching margin (1px 0) and
  border-radius (8px) via .toolButton.toolButtonActive CSS rule
- Removed line-height: 40px from .toolButton (flex centering handles
  vertical alignment, line-height was pushing icons up)
- MobileCoordButton now watches activeToolName store and deactivates
  when another tool opens (fixes coords staying active)
- MobileTimeUIToggle sets activeToolName='MobileTimeUI' when opening
  so coords/other buttons can detect it and deactivate
- MobileTimeUIToggle clears activeToolName when closing
- Both custom buttons skip self-deactivation via name check

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix cross-references: convert backtick refs to markdown links, add Devin knowledge notes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon height 40px, button margins for active padding

- #toolbar .toolButton i: height 40px fixes icon vertical alignment
- #toolbar .toolButton: margin 0 2px gives spacing between buttons
- #toolbar .toolButton.toolButtonActive: margin 1px 2px so active
  background has visual padding around the icon

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename knowledge/ to .knowledge/ for consistency with .specify/ convention

Dot-prefix signals agent infrastructure (not source code), consistent with
.specify/, .github/, .vscode/ conventions. All cross-references updated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mobile toolbar icon line-height 40px, active button padding via height

- Coord and TimeUI button <i> icons get line-height: 40px
- Active buttons: height 34px (vs 40px toolbar) creates visual padding
  around the active background, centered by flex align-items
- Buttons get margin: 0 1px for horizontal spacing

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix broken cross-reference: 06.2 -> 06.1-configure-rest-api.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: close active tool + cancel deferred cleanup in MobileCoordButton/TimeUI

- MobileCoordButton: call closeActiveTool() before opening, destroy
  _pendingCloseTool if set, increment _closeSeq to cancel deferred
  tools.innerHTML clear
- MobileTimeUIToggle: same _pendingCloseTool + _closeSeq fix after
  closeActiveTool() to prevent 420ms deferred cleanup from wiping
  #timeUI after it's placed in #tools
- Removed redundant closeActiveTool() from MobileCoordButton close path
  (was being called after destroy, not needed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: active mobile toolbar buttons 34x34px (square)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Drastically compress .knowledge/ — keep only unique agent content

Remove 33 wiki files that duplicate docs/pages/ content.
Remove 9 reference/ files derivable from source code.
Keep only 5 files (down from 46):
  - AI-GETTING-STARTED.md (agent setup walkthrough)
  - AI-DEVELOPMENT.md (spec-kit workflow)
  - conventions-and-gotchas.md (naming, code style, common issues)
  - 12-devin-knowledge-notes.md (CI, auth, DB init, security gotchas)
  - README.md (index pointing to docs/pages/ for everything else)

Principle: don't duplicate docs/ — only keep what's uniquely agent-optimized.
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Rename to knowledge-notes.md, remove Devin branding and fork-specific CI section

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: hide mmgis-map-logo on mobile

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Restore Database Safety Rules for AI Agents section in AGENTS.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: shift compass and map scale 6px to the right (both mobile and desktop)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add back Important Instructions, code pattern templates, and detailed project structure

- Important Instructions in AGENTS.md: MCP tools, hot-reload, Reference Mission
- .knowledge/code-patterns.md: full directory tree with key directory annotations,
  plus copy-paste templates for Express routes, Sequelize models, Tool plugins,
  and WebSocket handlers

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Update project structure trees to reflect current filesystem

Add missing directories: tests/, .knowledge/, .specify/, .github/, views/,
private/, spice/, build/, examples/, scripts/middleware.js.
Both abbreviated (AGENTS.md) and detailed (.knowledge/code-patterns.md) trees
now match the actual repo layout.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.30-20260501 [version bump]

* Add Layers_.js to project structure (key singleton L_)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix project structure: correct API layout, frontend modules, code templates

API/Backend/ uses feature-domain modules (Draw/, Users/, Config/, etc.)
with setup.js + routes/ + models/ per feature — not APIs/ or Databases/.
Frontend essence/ has Components/, Helpers/, LandingPage/, mmgisAPI/,
services/ — not Ancillary/. Basics/ includes all singletons (Globe_,
Formulae_, ToolController_, Viewer_, ComponentController_, Test_).
Code templates updated to match actual patterns (setup.js, module.exports).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove test infrastructure (Test_ module, testModules, DrawTool.test)

- Delete src/essence/Basics/Test_/ directory
- Delete src/essence/Tools/Draw/DrawTool.test.js
- Remove Test_ import and Shift+T keydown handler from essence.js
- Remove tests key from Draw tool config.json
- Remove testModules generation logic from API/updateTools.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.31-20260501 [version bump]

* style: move Cesium link button to top-right and match Leaflet zoom button styling

- Change control container from top-left to top-right positioning
- Update button size from 26px to 30px to match Leaflet zoom controls
- Use CSS variables (--color-a, --color-f, --color-mmgis) instead of hardcoded colors
- Add border-radius and box-shadow matching Leaflet control appearance
- Update hover/inactive states to use themed colors

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: anchor map logo to viewport instead of Leaflet map panel

- Change MapLogo parent from .leaflet-bottom.leaflet-right to #main-container
- Switch CSS position from absolute to fixed for viewport anchoring
- Add explicit bottom-offset positioning in BottomElementPositioner (desktop)
- Add explicit bottom-offset positioning in BottomElementPositioner (mobile)
- Logo stays at viewport right edge regardless of open side panels
- Retains smooth bottom offset transitions when bottom bar appears

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: remove references to deleted test infrastructure (Test_, DrawTool.test)

- Remove Test_/ from project structure in .knowledge/code-patterns.md
- Remove DrawTool.test.js references from specs/006 spec, plan, and tasks
- Remove Draw Tool Testing section from tasks.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.32-20260501 [version bump]

* fix: append logo to document.body to avoid filter containing block

#main-container has a CSS filter property which creates a new containing
block per the CSS spec, causing position:fixed to behave like absolute.
Appending to document.body ensures true viewport-fixed positioning.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: prevent mobile topBarTitleName text wrapping by replacing max-width with white-space: nowrap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 4.3.33-20260501 [version bump]

* chore: bump version to 5.0.0 and update changelog

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(ui): move Screenshot/Fullscreen to BottomBar, About to TopBar kebab

TopBar kebab menu now contains only Keyboard Shortcuts, Settings, and About
(About now shows on both desktop and mobile).

BottomBarReact now renders Screenshot, Fullscreen, and Copy Link buttons
(top to bottom) following the same IconButton + Tooltip pattern. The
About button has been removed from BottomBarReact.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(mobile): enforce exclusive panel toggling on mobile in TopBar

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* style: reposition LithoSphere globe controls to match Leaflet/Cesium theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* feat(topbar): hide Viewer/Globe toggles based on configured panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): reorder buttons (Copy Link, Screenshot, Fullscreen) and unify size

Reorder the BottomBarReact buttons top-to-bottom to: Copy Link, Screenshot,
Fullscreen.

Move the 24x24 button sizing from the #topBarLink id selector in mmgis.css
into the .barButton class in BottomBarReact.module.css so all three buttons
share the same compact size as the original Copy Link button. Drop the now
redundant #topBarLink rule.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(bottombar): increase padding-bottom to 12px and button margin to 3px 0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: rearrange globe controls — compass top-right circular, nav row, vertical column, panels open left

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* chore: bump version to 5.0.2-20260505 [version bump]

* style: anchor observe settings panel right:34px and float nav hover panels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(theming): add 5 new themes, --color-shadow variable, and configure ThemePreview

- Add Dark Terra, Dark Nebula, Dark Lunar, Dark Supernova, Light Botanical themes
- Add --color-shadow CSS variable to every theme + :root fallback
- Replace hardcoded rgba shadow colors with var(--color-shadow) in TopBar,
  Toolbar, SeparatedTools, ToolPanel, FloatingElements, Dropdown, Modal,
  and SplitScreens
- Add Custom shadowcolor color picker in tab-ui-config and apply it via Stylize
- Add ThemePreview component (configure/src) wired through Maker.js as
  a new 'themepreview' row type so the configure UI shows a live mini
  mockup of the selected theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.2-20260505 [version bump]

* fix(configure/ThemePreview): tighten top spacing and live-preview Custom theme

- Pull the preview up by 12px so the gap below the theme dropdown is tighter.
- Read the Custom color pickers (look.primarycolor / secondarycolor /
  tertiarycolor / accentcolor / shadowcolor / topbarcolor / toolbarcolor /
  mapcolor) from the configuration and overlay them on Dark Default so
  the preview reflects Custom theme edits live, matching Stylize.js's
  runtime behavior.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260505 [version bump]

* feat(themes): add Dark Heliosphere, Dark Monokai, and Light Solarized

- Dark Heliosphere: deep night purple surface with corona-orange accent.
- Dark Monokai: warm graphite surface with lime accent (Monokai-inspired).
- Light Solarized: classic solarized base3/base02 with blue accent.

Mirror added to configure/src/themes/themes.js for the ThemePreview, and
the three names appended to the Color Theme dropdown options.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(coordinates): respect time.initiallyOpen when live deep-link is set

* chore: bump version to 5.0.3-20260505 [version bump]

* refactor(theming): remove Custom theme + per-field color overrides

- Drop the 'Custom' option from the Color Theme dropdown.
- Remove all Custom Color Options (look.primarycolor, .secondarycolor,
  .tertiarycolor, .accentcolor, .bodycolor, .topbarcolor, .toolbarcolor,
  .mapcolor, .hightlightcolor, .shadowcolor) from tab-ui-config.json.
- Strip the matching DOM/CSS-variable override block from Stylize.js;
  Stylize now just applies the selected preset theme (and the page logo).
- Drop the empty bodycolor/topbarcolor/toolbarcolor/mapcolor/shadowcolor
  defaults from API/templates/config_template.js.
- Simplify ThemePreview to render the selected preset directly — no
  Custom branch, no overlay logic.

Preset themes cover all the looks we want and keep the configure surface
much smaller.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(time-ui): round corners on TimeUI shell, action wrappers, mode dropdown

- #timeUI: 10px border-radius on the outer time control bar.
- #mmgisTimeUIActionsLeft / #mmgisTimeUIActionsRight: 10px border-radius
  so the action clusters sit as rounded chips.
- #mmgisTimeUIActionsRight > div (excluding #mmgisTimeUIPresent): 10px
  border-radius on each action button so they match the wrapper.
- #mmgisTimeUIModeDropdown: 40px height + 10px border-radius to align
  with the rest of the bar; clear the dropy default border-color so the
  rounded edge isn't outlined.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260505 [version bump]

* feat(configure): mark light themes as (experimental) in dropdown label

Light themes still have outstanding contrast issues, so flag them in the
Color Theme dropdown without changing the saved value.

- Maker dropdown now accepts options as either a plain string (current
  behavior) or { value, label } so the rendered label can differ from
  the persisted value.
- tab-ui-config switches the six light themes to { value, label } form
  with '(experimental)' appended to the label only. Existing mission
  configs that already saved 'Light Default' etc. continue to match.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Fix timeUI border radius

* fix(mobile): rescue #timeUI before tool make() destroys it

Clicking Layers -> Time -> Layers -> Time on mobile caused the bottom
panel to render LayersTool content with TimeUI height. The #timeUI DOM
element was destroyed when LayersTool.make() called $('#tools').empty(),
before the async React useEffect in MobileTimeUIToggle could rescue it
to its staging container.

- ToolController_.makeTool: synchronously move #timeUI from #tools back
  to #timeUIMobileStaging (and reset TimeUI store flags) on mobile,
  before invoking the new tool's make().
- MobileTimeUIToggle.handleClick: defensive fallback that re-initializes
  TimeUI if #timeUI no longer exists when the toggle is activated.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): move re-initialized #timeUI from staging into #tools

TimeUI.init() on mobile appends the new #timeUI to the hidden
#timeUIMobileStaging container, so the fallback branch must also move
it into #tools — otherwise the user sees an empty tool panel after
the destroyed-element recovery path.

Caught by Devin Review.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): preserve #timeUI when Coordinates tool empties #tools

On mobile, opening or closing the Coordinates tool runs
$('#tools').empty() inside interfaceWithMMWebGIS / separateFromMMWebGIS.
After the previous PR commits, clicking Coordinates -> Time still left
the bottom panel empty because:

- Coordinates.make() empties #tools while #timeUI is in staging (fine
  on its own), but the Coordinates teardown that fires after the user
  switches to the Time toggle (via MobileCoordButton's useEffect on
  activeToolName change) calls Coordinates.destroy() ->
  separateFromMMWebGIS(), which empties #tools wholesale and destroys
  the freshly-placed #timeUI.

Add a rescueMobileTimeUI() helper that moves #timeUI from #tools back
to #timeUIMobileStaging before each tools.empty() call in Coordinates,
mirroring the rescue already done in ToolController_.makeTool().

Coordinates -> Time now correctly shows the TimeUI.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mobile): harden TimeUI fallback recovery (call fina(), de-dupe popovers)

Devin Review correctly flagged that the safety-net path in
MobileTimeUIToggle.handleClick was producing a partially-broken TimeUI
when it fired:

- TimeUI.init() unconditionally appends a new #timeUIPlayPopover_global
  to <body>, so a second init() left two elements with the same id.
- TimeUI.init() alone does not wire up date pickers or per-button click
  handlers — that's TimeUI.fina()'s job. Without fina(), the recovered
  TimeUI rendered visually but Play / Previous / Next / Fit / Follow /
  Present / Expand were all dead.

Before re-initializing, remove the stale #timeUIPlayPopover_global and
#timeUIQuickSelectPopover_global divs to avoid duplicate ids. After the
new #timeUI is moved into #tools, call TimeUI.fina() to populate the
date pickers, attach the button click handlers, build the histogram,
and populate the expanded mobile rows.

Some delegated body/document handlers in attachEvents() will still be
duplicated on this path; that is acceptable for a degraded recovery
that should never run in practice now that the primary rescues in
ToolController_.makeTool() and Coordinates.js cover all known paths.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260505 [version bump]

* fix(mobile): Coordinates teardown only removes its own DOM

The previous Coordinates fix was racing with itself: after the Time
toggle synchronously moved #timeUI into #tools, MobileCoordButton's
useEffect (triggered by the activeToolName change) ran on the next
React tick and called L_.Coordinates.destroy(). That called
separateFromMMWebGIS(), whose rescue moved #timeUI right back into the
hidden staging div before tools.empty() — so the bottom panel ended up
empty even though the time toggle was 'active'.

Make separateFromMMWebGIS selective: only remove the
Coordinates-specific DOM (#coordUIHeader and #CoordinatesDiv) instead
of wiping all of #tools. Any other content already in #tools (e.g.
#timeUI placed there by the Time toggle) is left alone.

interfaceWithMMWebGIS still keeps the rescue + tools.empty() pattern
on the open path so Coordinates always starts from a clean panel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Bump DrawTool Temporal Drawings upward

* chore: bump version to 5.0.6-20260505 [version bump]

* chore: reset version to 5.0.0

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test(e2e): fix 9 pre-existing failures (test-only changes)

- mmgis-api.spec.js: add form-fill login under AUTH=local; serialize
  describe to avoid concurrent-login race in the session store
- coordinates.spec.js: TimeUI toggle was moved from the coordinates bar
  to the Settings modal; navigate via topbar kebab menu and assert the
  checkbox is rendered
- widgets.spec.js: target .leaflet-control-zoom-in/-out specifically;
  the bare .leaflet-control-zoom class is also used by the home/reset
  control, so the original assertion was always false
- sites.spec.js: scope panel selector to #toolPanel; both the toolbar
  icon and the panel container share id="SitesTool"

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260505 [version bump]

* Revert "chore: bump version to 5.0.1-20260505 [version bump]"

This reverts commit 4880204c1163be5d1d7fa96d14a0ed018c6f586c.

* fix: prevent filter operator dropdown clipping in Layers panel

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.1-20260507 [version bump]

* revert: keep dropy openUp:true for operator dropdowns

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit d67c369ed437e47d658ae051348d377978dc48ed.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 29565ed829a55e9c241a789c9a3901d11cb5ca67.

* chore: bump version to 5.0.1-20260507 [version bump]

* Revert "chore: bump version to 5.0.1-20260507 [version bump]"

This reverts commit 50e357604ebe9378564619b34c508b63cfb62c1d.

* chore: bump version to 5.0.1-20260507 [version bump]

* chore: bump version to 5.0.2-20260511 [version bump]

* fix: render Globe panel immediately on first open without window resize

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.3-20260511 [version bump]

* feat: add theme borders to panels and gradient backgrounds to splitters

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: bump split shadow gradient opacity to 0.4

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal 3-col grid + smaller leaflet zoom button gap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: prevent hotkey label/value wrapping (ellipsis instead)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: hotkeys modal single column, no wrap, no truncation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.4-20260511 [version bump]

* style: hotkeys modal dividers, invert title/subtitle colors, rename title, margin above subtitles

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style: move splitter gradient to themed CSS class, restore hover feedback

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* style: hotkeys section titles use --color-h (matches rest of app)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.5-20260511 [version bump]

* fix: guard Globe_.init() inside rAF to prevent double instantiation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.6-20260512 [version bump]

* feat(plugins): per-plugin deps, lazy tool loading, validation, shared discovery

Phase 3 — Plugin config validation + override warnings:
- New API/pluginValidation.js with validatePluginConfig() for tool, component,
  and backend manifests. Validates required fields (name, paths), object/string
  shape of paths, dependencies block (npm/python.pip/python.conda), and warns
  on unknown top-level fields.
- updateTools()/updateComponents() now skip invalid plugins and emit override
  warnings (matching what components already logged for tools).

Phase 2 — Shared discoverPlugins() utility:
- New API/pluginDiscovery.js consolidates the duplicated scanning logic from
  updateTools(), updateComponents(), and getBackendSetups(). Supports exact-
  name and substring container patterns, JSON/require/no-op loaders, and skips
  dot/underscore-prefixed dirs.
- updateTools.js and setups.js refactored on top of the shared helper.

Phase 1 — Per-plugin dependency declaration + build-time aggregation:
- Plugin config.json may now declare a 'dependencies' block (npm + python.pip +
  python.conda). validatePluginConfig() also validates this shape.
- New scripts/resolve-plugin-deps.js scans every tool/component/backend plugin
  and writes plugin-package.json, plugin-python-requirements.txt, and
  plugin-conda-deps.txt. Detects version conflicts and fails loudly.
- scripts/build.js calls resolvePluginDeps() before updateTools().
- Dockerfile installs the aggregated plugin npm and pip deps after the root
  npm ci, using --no-save / --no-package-lock / --ignore-scripts so the root
  lockfile is untouched.
- Animation tool migrated: ffmpeg/gifshot/html2canvas now declared in its
  config.json (kept in root package.json for transitional compat).
- Generated artifacts gitignored.

Phase 4 — Lazy loading of tool bundles:
- updateTools() now emits dynamic-import arrow functions in the generated
  src/pre/tools.js with webpackChunkName hints so each tool is split into
  its own chunk (Kinds stays static because it's required synchronously).
- ToolController_ gains ensureToolLoaded(name) and getLoadedTool(name) helpers
  and makeTool is async; init/finalizeTools and the separated-tool auto-open
  flow are updated to handle lazy modules.
- Toolbar.jsx, SeparatedTools.jsx, SitesTool.js, and Layers_.js migrated to
  resolve LayersTool/etc. via the new helpers instead of poking toolModules
  directly.

Tests & docs:
- tests/fixtures/test-plugin-tools/{TestPlugin,InvalidPlugin,OverridePlugin}
  + tests/helpers/plugin-helpers.js with install/uninstall helpers.
- New unit specs: pluginValidation, pluginDiscovery, updateTools,
  resolvePluginDeps, toolLazyLoading (57 tests, all passing).
- CONTRIBUTING.md and docs/pages/Contributing/Contributing.md updated with
  schema, override behaviour, dependency declaration, build-time aggregation,
  conflict detection, and Docker integration.

* chore: bump version to 5.0.7-20260512 [version bump]

* fix: make Globe_.init() idempotent against multi-init

Globe_.init() previously constructed a fresh GlobeRenderer on every call,
which after #71 could happen multiple times for a single toggle (uiStore
setTimeout + TopBar rAF). Each extra construction appends another
.cesium-widget / _lithosphere_scene to #globe and leaves event handlers
wired to dereferenced renderer state, which has been observed to break
LithoSphere globe control buttons on configurations where the globe panel
starts closed at boot.

Add a top-of-init() guard that bails out and calls invalidateSize() when
a renderer already exists. Single small, surgical change; no behavior
change for the !L_.hasGlobe mock-swap path or for first-time construction.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.7-20260512 [version bump]

* test(plugins): generate src/pre/tools.js on demand in toolLazyLoading spec

The Playwright unit-tests CI step runs before `npm run build` so the
gitignored `src/pre/tools.js` artifact does not yet exist on disk.
Add a beforeAll hook that invokes `updateTools()` to regenerate it
when missing, keeping the spec self-contained on both CI and dev
machines that already built locally.

* fix(tools): defensive getTool() + preload flag for cross-referenced tools

Devin Review flagged a behavioural regression introduced by Phase 4:
`ToolController_.getTool(name)` previously always returned a method-
callable object (real module or `{ use(){} }` stub) because every tool
was statically imported. After Phase 4, unresolved lazy loaders are
`() => import(...)` functions, so callers like `Map_.getTool('InfoTool').use(...)`,
`mmgisAPI.getTool('DrawTool').filesOn`, and `LegendTool` calling
`LayersTool.populateCogScale` would crash with TypeError until the
target tool was opened.

Two fixes:

1. **Defensive getTool()**: Returns the legacy fallback stub when the
   tool module is still a lazy-loader function, and fires off
   `ensureToolLoaded(name)` in the background so subsequent calls see
   the resolved module. Prevents all crashes immediately.

2. **`preload: true` config flag**: Tools reached synchronously from
   other code paths (Info, Draw, Layers, Chemistry) now declare
   `"preload": true` in their `config.json`. `ToolController_.init()`
   calls `preloadEagerTools()` which fires `ensureToolLoaded` for
   every such tool right after toolbar setup — the chunks download
   in parallel with the rest of the page becoming interactive, so by
   the time a user clicks a feature the InfoTool module is already
   resolved.

`validatePluginConfig` now accepts `preload` as a known tool field;
CONTRIBUTING.md and docs/pages/Contributing/Contributing.md updated to
document when to set it. Added a unit test covering the defensive
getTool behaviour and the `preload` propagation through
`toolConfigs`.

* chore: bump version to 5.0.8-20260512 [version bump]

* revert(plugins): remove Phase 4 lazy tool loading and preload mechanism

Phase 4 lazy emission caused cross-tool consumers (Map_ feature-click,
mmgisAPI, LegendTool) to receive raw '() => import(...)' arrows from
ToolController_.getTool(), breaking InfoTool open. Reverting to the
pre-Phase-4 behavior of static tool imports.

- API/updateTools.js: generated src/pre/tools.js now emits
  'import FooTool from ...' for every tool (Kinds stays static too).
- ToolController_.js: getTool/makeTool back to sync; ensureToolLoaded,
  getLoadedTool, preloadEagerTools deleted; separated-tool auto-open
  flow simplified to direct sync calls.
- Toolbar.jsx, SeparatedTools.jsx, Layers_.js: revert async/lazy
  patterns to sync ToolController_.toolModules[name] access.
- API/pluginValidation.js: drop 'preload' from KNOWN_FIELDS.
- src/essence/Tools/{Info,Draw,Layers,Chemistry}/config.json: drop
  'preload: true'.
- CONTRIBUTING.md + docs: remove preload documentation.
- tests/unit/toolLazyLoading.spec.js: rewrite to verify static
  imports instead of lazy loaders.

Also: log standard backends at startup (parity with plugin backends
and with tools/components), so all backends now produce
'info  Loaded backend: <name> from <container>' at boot.

Phases 1-3 (per-plugin dependency aggregation, shared discoverPlugins,
config validation + override warnings) are unaffected.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(logger): new 'loaded' level (purple bg) for tool/component/backend startup

Previously the 'Loaded tool/component/backend: X from Y' lines used
the generic blue 'info' tag. They now use a dedicated 'loaded' level
rendered with a purple (#a855f7) background, so plugin discovery
output is visually distinct from other info messages.

- API/logger.js: add 'loaded' case to the dev-mode switch (white text
  on purple bg) and suppress the redundant 'Caller:' echo for it
  (matches how 'info' and 'success' are handled).
- API/updateTools.js: registerPlugin now logs at level 'loaded'.
  Drops the redundant 'Loaded ' prefix since the level tag now reads
  'loaded'.
- API/setups.js: standard and plugin backend startup logs use the
  new level, same drop of the 'Loaded ' prefix.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(logs): 'Plugging in Tools/Components/Backends...' headings

Rename the cyan banner messages in scripts/build.js and scripts/server.js
from 'Updating Tools...' / 'Updating Components...' to 'Plugging in
Tools...' / 'Plugging in Components...' so the headings match the
plugin terminology used everywhere else (plugin-package.json,
discoverPlugins, etc.).

Also add a matching 'Plugging in Backends...' banner before
setups.getBackendSetups() in scripts/server.js so backends get an
equivalent title block to tools and components.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(logs): cyan banner lines lead with a blank line instead of trailing one

Move the \n from the end to the beginning of every cyan banner in
scripts/build.js and scripts/server.js (Resolving Plugin Dependencies,
Plugging in Tools/Components/Backends, Validating Environment
Variables, Starting websocket, Starting the development server) so
that the blank line visually separates each section above its title
rather than below it.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(plugins): postinstall hook auto-installs plugin npm deps

Plain `npm install` (or `npm ci`) on a fresh clone now resolves and
installs every plugin's declared npm dependencies automatically, so
new developers don't need to remember a second command.

- scripts/install-plugin-deps.js (new): reads plugin-package.json,
  filters out deps already declared in root package.json with the
  same version specifier (no-op for the Animation transitional case),
  installs the remainder with `npm install --no-save --no-package-lock
  --ignore-scripts <pkg@ver> ...`. `--no-package-lock` keeps the
  root lockfile clean; `--ignore-scripts` prevents the inner install
  from re-entering postinstall and matches the Dockerfile.
- package.json: postinstall guards against the Dockerfile's
  package.json-only layer (`scripts/` not copied yet) by checking
  for the two script files via `node -e` before invoking them.
  Adds a `plugins:install` npm script for on-demand runs.
- CONTRIBUTING.md + docs/pages/Contributing/Contributing.md: replace
  the manual-install paragraph with a note about the postinstall
  hook and the filtering behavior.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs(plugins): manual Python install step for plugin pip/conda deps

Document that the npm postinstall hook only handles plugin npm deps —
plugin pip/conda deps must be installed manually after creating the
Python environment, since there's no portable way to detect which
interpreter or environment to target from a Node script.

- CONTRIBUTING.md: added a 'For local development ... Python' block
  with the explicit `node scripts/resolve-plugin-deps.js` +
  `micromamba run -n mmgis pip install -r plugin-python-requirements.txt`
  + optional conda install commands.
- docs/pages/Contributing/Contributing.md: matching short blurb in
  the user-facing docs.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs(install): plugin Python install step in Installation.md

Add a numbered step in docs/pages/Setup/Installation/Installation.md's
Setup sequence (right after `micromamba activate mmgis`) with the
`pip install -r plugin-python-requirements.txt` and optional
`micromamba install --file plugin-conda-deps.txt` commands, so
non-Docker installs have the step in their main flow rather than
buried in CONTRIBUTING.md.

Also adds a pointer from the Python Environment section earlier in
the same file (after the env-create + activate steps) back to the
numbered Setup step.

CONTRIBUTING.md and docs/pages/Contributing/Contributing.md are
slimmed: instead of duplicating the install commands, both now link
to the Installation page (this matches the user request — the install
commands live in installation docs, not contribution docs).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: serialize concurrent layer reloads and stop mutating layer.url

Concurrent mmgisAPI.reloadLayer() calls for the same layer were
silently dropped by the _layersBeingMade guard, and reloadLayer
mutated layer.url in-place during async work — causing a race where
a second reload would capture the resolved URL as its 'original' and
permanently corrupt the {starttime}/{endtime} template placeholders.

Changes:
- TimeControl.reloadLayer: compute resolvedUrl into a local variable
  instead of mutating layer.url. Pass resolvedUrl through to
  Map_.refreshLayer.
- Map_.refreshLayer: accept resolvedUrl; temporarily swap layer.url
  inside a try/finally for the makeLayer call so the fetched URL is
  the resolved one and the placeholder template is always restored.
- Map_.refreshLayer: when a reload is already in flight for the
  same layer, queue the request (coalesced by name) instead of
  dropping it with a 'Cannot make layer' warning.
- Map_.makeLayer: after releasing the lock, drain any queued reload
  for this layer via setTimeout 0.
- TimeControl.reloadTimeLayers: become async and await Promise.all
  of every per-layer reload; remove the setTimeout(500) workaround
  around active-feature restoration and follow-pan logic.
- mmgisAPI.reloadLayers: new batch API that reloads multiple
  time-enabled layers concurrently and returns a Promise<boolean[]>.
- Tests: new tests/e2e/map/concurrent-layer-reload.spec.js covers
  the seven scenarios from the plan (single reload, URL preservation,
  multi-layer concurrent reload, rapid same-layer reload, reloadLayers
  API surface). tests/e2e/api/mmgis-api.spec.js gains a surface
  check that reloadLayer/reloadLayers are exposed.
- Docs: Main.md documents the new reloadLayers entry.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.8-20260513 [version bump]

* fix(TimeControl): use Promise.allSettled in reloadTimeLayers

Promise.all short-circuits on first rejection, which would skip the
active-feature restoration and follow-pan logic if any single layer
reload threw (network error, malformed config, etc.). The old
setTimeout(500) approach ran those steps unconditionally; switching
to Promise.allSettled preserves that robustness.

Addresses Devin Review feedback on PR #78.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(mmgisAPI): use Promise.allSettled in reloadLayers batch API

Mirrors the reloadTimeLayers fix: a single failing TimeControl.reloadLayer
call (e.g. unknown layer name throws inside asLayerUUID, network error,
malformed config) no longer rejects the whole batch. The returned array
preserves order and reports failed entries as false instead, matching
the documented Promise<boolean[]> contract.

Addresses second Devin Review finding on PR #78.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: note that reloadTimeLayers is now async (Promise<string[]>)

The implementation changed from sync to async in commit a096cfe9 (the
function now uses await + Promise.allSettled internally to coordinate
per-layer reloads), but the public API JSDoc and Main.md still
documented the old synchronous return type. External consumers using
the old synchronous return value would get a Promise instead of an
array.

Updates JSDoc on mmgisAPI.reloadTimeLayers to declare
Promise<string[]>, and rewrites the Main.md example to use 'await'.
Also fixes the previous example, which had a syntactically-malformed
trailing tuple-style index.

Addresses third Devin Review finding on PR #78.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: split time-related e2e specs into tests/e2e/time/

Moves the two time-feature specs out of tests/e2e/map/ so the map suite
stays focused on map-UI behavior and the time/time-enabled-layer suite
can be run (and reasoned about) on its own:

- tests/e2e/map/time-control.spec.js
    -> tests/e2e/time/time-control.spec.js
- tests/e2e/map/concurrent-layer-reload.spec.js
    -> tests/e2e/time/concurrent-layer-reload.spec.js

Relative imports (../../helpers, ../../pages, ../../fixtures) are
unchanged because the new directory is the same depth. Playwright
picks the files up automatically via testDir './tests' +
testMatch '**/*.spec.js'.

Adds a matching 'npm run test:e2e:time' script and documents the new
suite in tests/README.md alongside the existing per-suite scripts.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: cap local Playwright workers at 4

The previous `workers: process.env.CI ? 1 : undefined` resolved to
Playwright's default (~half the CPU cores). On higher-core machines
(e.g. 16 cores -> 8 workers) the dev server gets overloaded and the
suite actually runs slower. CI behavior is unchanged (1 worker).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: thread resolvedUrl through makeLayer/captureVector instead of mutating layer.url

The previous fix in Map_.refreshLayer temporarily swapped
`layerObj.url = resolvedUrl` during the `await makeLayer` call and
restored it in a finally block. Since `layerObj` is the shared
`L_.layers.data[name]` object, any concurrent code reading
`layer.url` during that async window could observe the resolved URL
instead of the template. Most importantly, a second
`TimeControl.reloadLayer()` call would then capture the resolved
URL as its 'template' and corrupt the placeholders for every
subsequent reload.

Surfaced by tests/e2e/time/concurrent-layer-reload.spec.js Test 5,
which after Promise.all of two reloads observed
`layer.url ==='geodatasets:...?from=...&to=...'` instead of the
expected `{starttime}/{endtime}` template.

Fix: thread `resolvedUrl` as an explicit parameter through
`Map_.refreshLayer` -> `makeLayer` -> `makeVectorLayer` ->
`captureVector` (via options.resolvedUrl). `captureVector` uses
`options.resolvedUrl` when provided and skips the
`{starttime}`/`{endtime}`/`{customtime.*}` regex replacement
(which TimeControl.reloadLayer already performed). `layer.url` is
NEVER mutated for the duration of the async operation, so the
template is preserved across overlapping reloads.

This also fully resolves the Devin Review #4 finding which flagged
the temporary URL swap as reintroducing the same race the PR was
meant to fix.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: also replace {time} placeholder when computing resolvedUrl

In the previous fix, captureVector skips its time-replacement block when
the caller supplies options.resolvedUrl (because TimeControl.reloadLayer
already performed those replacements). However TimeControl.reloadLayer
was only replacing {starttime}/{endtime}/{customtime.*} on resolvedUrl,
not {time} — which captureVector previously handled at
LayerCapturer.js:97 by mapping {time} -> endTime. This caused vector
layers using the documented {time} placeholder (see
docs/pages/Configure/Layers/Tile/Tile.md:90 and
docs/pages/APIs/JavaScript/Main/Main.md:482) to fetch URLs containing
the literal text '{time}' on time-triggered reloads.

Mirror the existing captureVector behavior: replace {time} with the
formatted end-time value alongside {starttime}/{endtime}, before the
resolved URL is threaded through Map_.refreshLayer -> makeLayer ->
captureVector.

Addresses Devin Review finding on commit fcba8101.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: always run time-placeholder replacement in captureVector (idempotent)

The previous fix gated captureVector's time-placeholder replacement
block on `!hasResolvedUrl`, on the assumption that any caller passing
options.resolvedUrl had already done the replacement.

That assumption only holds for time.type === 'global' / 'requery' /
forceRequery. For other time types that still flow through
Map_.refreshLayer into captureVector (most importantly
time.type === 'local' with endProp == null per TimeControl.js:276-287),
TimeControl.reloadLayer's resolved-URL replacement block at lines 249-273
is skipped, so the resolvedUrl arrives at captureVector still containing
literal {starttime}/{endtime}/{time} placeholders. The fetch then goes
out with unreplaced placeholders.

Fix: drop the !hasResolvedUrl guard and always run the replacement,
reading the source from `layerUrl` (which is already either
options.resolvedUrl or layerObj.url per the choice above). The
.replace(/{starttime}/g, ...) chain is idempotent on URLs that have
already been resolved — the regexes simply don't match — so the
correct path is restored without re-introducing the mutate-in-place
bug fcba8101 fixed.

Addresses Devin Review finding on commit ddb90dbb.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: remove GIBS MODIS time tile test (always skips on external dependency)

The test at tests/e2e/time/time-control.spec.js depended on
gibs.earthdata.nasa.gov being reachable from the test environment and
served only as a placeholder — it skips every run since the test
infrastructure does not have external network access by policy. It
provides no signal in CI or locally, so removing it reduces noise in
the suite output without losing coverage.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: add coverage for {time}, local-endProp-null, stress, and feature-presence cases

Four new e2e tests in tests/e2e/time/concurrent-layer-reload.spec.js,
each targeting a gap the original suite missed:

Test 8 — {time} placeholder preservation. Mirrors Test 2/5 but uses
`{time}` instead of `{starttime}/{endtime}`. Catches the Devin
Review #5 regression where captureVector's gating on !hasResolvedUrl
silently dropped the {time} -> endTime replacement (the literal
'{time}' would have ended up in the fetch URL).

Test 9 — local + endProp==null path. Sets layer.time.type='local'
and layer.time.endProp=null to force the TimeControl.reloadLayer
branch (TimeControl.js:276-287) that bypasses the resolved-URL
placeholder block and falls through to the else clause. Inspects
outgoing /geodatasets/* requests via page.on('request', ...) and
asserts no literal {starttime}/{endtime}/{time} remain. Catches the
Devin Review #6 regression: when this branch hit captureVector with
hasResolvedUrl=true, the !hasResolvedUrl gate previously short-
circuited the only remaining replacement site.

Test 10 — 20-reload stress burst. Extends Test 4's two-reload check
to 20 concurrent reloadLayer() calls, capturing 'Cannot make layer'
warnings to verify the queue coalesces requests instead of silently
dropping them. Also re-asserts layer.url template integrity post-
burst.

Test 11 — Feature-presence after concurrent reload. Captures
L_.layers.layer[key].getLayers().length before and after a 5-reload
burst. Asserts the count is still > 0 afterwards — the user-visible
'gaps where dynamically-appearing data doesn't show up' symptom from
the original bug report.

All four tests skip gracefully when their fixture layer is absent or
the dataset returns no rows, to avoid spurious failures across
mission configurations.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: add 5 edge-case tests probing the blast radius of the URL fix

Five additional tests in tests/e2e/time/concurrent-layer-reload.spec.js
covering paths adjacent to the URL-mutation fix that were not exercised
by tests 1-11. Each was chosen because the production code on that path
changed (or now has a different contract) and a regression would not be
caught by the original race-condition tests.

Test 12 — {customtime.N} placeholder preservation. TimeControl.reloadLayer's
customtime replacement loop was migrated from `layer.url = ...` to
`resolvedUrl = ...`. Seeds TimeControl.customTimes.times so the loop
actually runs, then asserts the {customtime.0} placeholder remains
literally on layer.url after reload.

Test 13 — mmgisAPI.reloadTimeLayers() returns a Promise. This is the
backward-incompatible behavior change documented in
docs/pages/APIs/JavaScript/Main/Main.md (previously synchronous).
Asserts the returned value is a thenable that resolves to an array,
pinning the new contract so it does not silently regress.

Test 14 — mmgisAPI.reloadLayers handles unknown layer names. The
Promise.allSettled change requires that a failing per-layer reload
surfaces as `false` at the same array position as its input name,
without throwing. Mixes a valid name + an unknown name + another
valid name to verify the order and the boolean mapping.

Test 15 — Reloading a time-DISABLED vector layer leaves layer.url
unchanged. Discovers a candidate layer from L_.layers.data at runtime
(skips if none exist), reloads it, and asserts the URL is byte-equal
afterwards. Catches any accidental URL mutation introduced for the
non-time code path.

Test 16 — mmgisAPI.reloadLayers handles empty array, null, undefined,
and string inputs without throwing — verifying the Array.isArray guard
at mmgisAPI.js:618. Returns `[]` in all cases.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(makeLayer): wrap dispatch in try/finally so lock + queue drain always run

Address Devin Review finding: a thrown exception from any layer-type
builder (makeVectorLayer, makeVelocityLayer, etc.) inside makeLayer's
switch statement previously left lockRegistry[layerName] set to true
and skipped the queue drain entirely, since the release statement and
queue-drain block both lived AFTER the awaited dispatch.

Effect of the bug: any subsequent refreshLayer call for that layer
would queue against a permanently-locked entry that never drains.
The new queue mechanism inherits this pre-existing issue and makes
the failure mode worse — silent accumulation in _layerReloadQueue
instead of a visible 'Cannot make layer' warning.

Additional concern: the outer 'new Promise(async (resolve, reject) =>
{...})' is the async-executor anti-pattern. A throw inside the async
executor escapes to the unhandled-rejection handler instead of
rejecting the outer Promise — so the caller's 'await makeLayer(...)'
would hang indefinitely, compounding the lock-leak symptom.

Fix:
  - Wrap the type-dispatch switch + Filtering.updateGeoJSON/
    triggerFilter calls in a try/catch/finally.
  - catch logs the error and tracks success via 'madeSuccessfully'.
  - finally runs unconditionally: lockRegistry[layerName] = false,
    drain L_._layerReloadQueue[layerObj.name] if present, then
    resolve(madeSuccessfully). This ensures the lock release and
    queue drain happen regardless of whether the inner builder
    threw or completed normally.

Test (concurrent-layer-reload.spec.js):
  Added probe-style test '_layersBeingMade lock is released after
  single and concurrent reloads' that asserts the lock invariant:
    1. After mmgisAPI.reloadLayer() resolves +
       a 100ms drain window, _layersBeingMade[key] is false.
    2. After 5 concurrent mmgisAPI.reloadLayer() calls resolve +
       a 1000ms drain window, _layersBeingMade[key] is false.
    3. _layerReloadQueue is empty afterwards (otherwise a future
       reload would mistakenly trigger an immediate drain instead
       of doing its own work).

  This is a positive-invariant test — it catches accidental lock
  retention even without force-triggering exceptions, which would
  require monkey-patching webpack-internal module references that
  aren't exposed on window.

Per user direction, NOT addressing Devin Review's separate finding
about the reloadTimeLayers sync->async breaking change at this
time (no version bump requested).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.9-20260513 [version bump]

* fix(OperationsClock): bump z-index above bottomFloatingBar so clock stays visible

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(plugin-deps): respect override semantics when aggregating dependencies

When a plugin tool/component/backend overrides a standard one by reusing
the same directory name, only the override's deps should contribute to
the aggregated plugin manifests. Previously, gatherDependencies()
concatenated standard + plugin entries and fed both to mergeNpm/
mergePython, which could spuriously flag the same package as
conflicting between the standard and override versions.

Extract winnersByName() (mirroring API/updateTools.js +
API/setups.js override behavior) and use it for all three plugin
kinds. Add unit tests covering the override case and the
spurious-conflict regression.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(Dockerfile): re-install plugin npm deps in runtime stage

Plugin deps installed in the builder via `npm install --no-save
--no-package-lock` aren't recorded in package.json/package-lock.json,
so the runtime stage's `npm ci --only=production` would lose them.
Frontend deps are bundled by webpack into ./build so they're fine, but
backend plugins that `require()` their declared npm dependencies at
runtime would crash with 'Cannot find module'.

Copy plugin-package.json from the builder and re-run the same
conditional install in the runtime stage so backend plugin deps land
in the runtime image's node_modules. `--ignore-scripts` prevents the
inner install from re-entering the root postinstall hook.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(OperationsClock): lift to 58px bottom when TimeUI is open

Add #operationsClock to BottomElementPositioner's reactive positioning
so it shifts to bottom:58px when timeUIActive is true (TimeUI dock
visible) and back to bottom:40px when closed. Avoids overlap with
the bottom floating bar without adding new state to OperationsClock
itself. Mobile path is unchanged — OperationsClock.setupMobilePositioning
manages mobile positioning separately.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(OperationsClock): always sit at bottom:58px

Revert the dynamic positioning in BottomElementPositioner and just
hardcode bottom:58px in OperationsClock.css. Simpler, no cross-cutting
state dependency.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add Lunar South Pole reference mission variant (IAU2000:30120)

- Add REFERENCE_MISSION_VARIANTS registry and resolveVariantBlueprintPath
  helper to missionTemplates.js for dynamic variant resolution
- Update configs.js to accept referenceMissionVariant parameter and
  validate against the registry
- Add variant dropdown to NewMissionModal UI when Reference Mission
  checkbox is enabled
- Create blueprint directory with south polar stereographic config
  (IAU2000:30120, +proj=stere +lat_0=-90, bounds ±1095700/1095600)
- Add unit tests for variant registry, blueprint path resolution, and
  Lunar-SouthPole projection assertions (15 tests)
- Add E2E smoke tests for Lunar-SouthPole mission variant

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(api): pass through reloadLayer flags in reloadLayers

Forward evenIfOff, evenIfControlled, forceRequery, and
skipOrderedBringToFront parameters to TimeControl.reloadLayer()
for each layer in the batch. Fully backward-compatible — existing
callers that pass only layerNames get undefined for all flags.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.10-20260518 [version bump]

* chore: bump version to 5.0.10-20260518 [version bump]

* docs(api): add forceRequery & skipOrderedBringToFront to reloadLayer/reloadLayers docs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add SPole basemap to lunar ref mission

* chore: bump version to 5.0.11-20260518 [version bump]

* chore: remove unused blueprints/Missions/Test directory

The Test blueprint is no longer needed — the Reference-Mission and
Reference-Mission-Lunar-SouthPole blueprints serve as the basis for
demo, development, and testing.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: use setupReferenceMission string value as variant key fallback

Address Devin Review feedback: when setupReferenceMission is a non-empty
string (e.g. 'Lunar-SouthPole'), use it as the variant key if
referenceMissionVariant is not provided. Also guard against empty string
triggering reference mission creation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(measure): rename config DEM field from 'dem' to 'url' and handle HTTP URLs

- Rename 'field': 'dem' → 'url' and 'name': 'DEM Path' → 'DEM URL' in
  src/essence/Tools/Measure/config.json (layer-specific DEM objectarray)
- Rename same in configure/src/metaconfigs/layer-tile-config.json
- Add http:// and https:// prefix handling in makeProfile() so external
  URLs are not mangled with the mission path prefix

The top-level variables.dem field (primary DEM) is unchanged.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.11-20260519 [version bump]

* revert: remove http/https URL prefix handling in makeProfile()

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(measure): rename 'dem' to 'url' in Reference Mission layerDems blueprint

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(measure): accept both 'url' and 'dem' fields in layerDems for backward compatibility

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.12-20260519 [version bump]

* feat: add SPole_100m basemap layer to Lunar South Pole blueprint config

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: show Save to Base Blueprint button for all reference mission variants

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Update lunar ref mission

* style: add text-align right to mission list buttons in configure sidebar

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ui): SegmentTool background, clipboard API, context menu test selectors

- Add solid background to #segmentTool so panel is opaque
- Modernize copyToClipboard to use Clipboard API with execCommand fallback
- Add stable .ContextMenuMap class and #contextMenuMapCopyCoords id for E2E tests

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.12-20260521 [version bump]

* fix(contextmenu): increase z-index to 9999 so menu renders above other layers

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.13-20260521 [version bump]

* fix: use Object.hasOwn for variant key validation to prevent prototype chain leakage

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(security): type coercion for query params in geodatasets.js

Add string type checks for starttime, endtime, and format query parameters
to prevent SQL injection bypass via array coercion (e.g. ?starttime=a&starttime=b).

Fixes both /get and /search endpoint locations.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(security): prototype pollution guards across codebase

- Block __proto__, constructor, prototype keys in Utils.setIn2
- Use Object.create(null) for dirStore in middleware.js
- Filter dangerous column names in datasets model
- Guard field assignments in datasets routes
- Protect QueryURL property assignments from prototype pollution

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(security): add auth and compute rate limiters

- authLimiter: 10 req/15min on /login to prevent brute force
- computeLimiter: 30 req/min on Python-spawning utils routes
- Limiters passed through setup system to route files

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(security): harden GitHub Actions workflows

- Add permissions blocks to all workflow files
- Pin all third-party actions to commit SHAs
- Replace node -e require(package.json) with jq in bump-version.yml

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(security): sanitize log output to prevent log injection

Add sanitizeForLog() to strip ANSI escape sequences and control
characters from log messages, callers, and errors in both dev
and production modes.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: move Reference Mission docs to blueprints/README.md with variant guide

- Move Reference-Mission/README.md to blueprints/README.md
- Add 'Adding a New Reference Mission Variant' step-by-step guide
- Document all existing variants (Earth default, Lunar South Pole)
- Update cross-references in AGENTS.md and .knowledge/code-patterns.md

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(security): DOM XSS prevention with DOMPurify and .text()

- Add DOMPurify dependency and Sanitize.js service module
- Apply safeHTML() to CursorInfo, Modal, DrawTool_Files, DrawTool_Templater
- Replace .html() with .text() for numeric labels in LayersTool
- Escape single quotes in Filtering attribute values

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(security): move public static middleware before session

Move /public, /README.md, and /examples static routes before session
middleware to avoid setting unnecessary cookies on public assets.
Authenticated routes (/build, /configure/*) remain after session.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(tests): resolve CI failures in unit tests

- DOM XSS tests: remove document API usage (not available in Node context)
- Geodatasets: array format correctly defaults to safe string, not rejected
- Prototype pollution: use hasOwnProperty.call instead of direct property access

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: address Devin Review findings

- Rate limiters: wrap in deferred closure so lookup happens at request
  time, not module load time (users.js login + 6 utils.js compute routes)
- docker-build.yml: restore step-level env: blocks that were incorrectly
  replaced with permissions: at wrong indentation level

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add meandering south pole traverse line layer to Lunar South Pole blueprint

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Improve lunar ref mission blueprint config

* fix: skip auth rate limit for token re-auth + type coercion on /intersect

- authLimiter: skip for token-based re-auth (req.body.useToken) to avoid
  locking out legitimate users on page refresh
- geodatasets.js: apply same typeof string coercion to POST /intersect
  endpoint that was applied to GET endpoints

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: require MMGISUser cookie to skip auth rate limiter

Prevents bypass where attacker sets useToken:true without a valid
cookie to skip the rate limiter and brute-force credentials.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: address Devin Review round 3+4 findings

- Auth rate limiter: parse cookie and require valid token before
  skipping rate limit (prevents bypass via crafted falsy cookie)
- Modal.jsx: remove safeHTML (too restrictive for developer-authored
  modal content with forms, ids, inputs)
- DrawTool_Files.js: escape file_name/intent/file_…
* feat(ShadeTool): hover line on horizon chart + red time slider synced with playback

- Add vertical dashed line on horizon chart following mouse (matches map azimuth line)
- Add red draggable time slider on both horizon and visibility charts
- Time slider is bidirectionally synced with playback section slider
- Click/drag on either chart scrubs the playback frame
- Horizon chart scrubs by nearest azimuth match, visibility scrubs by time position

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): remove ancillary sun/earth trajectories from horizon chart

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): reduce visibility timeline panel height to ~85px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): replace horizon vertical slider with horizontal time controls bar

- Remove red vertical azimuth-based slider from horizon chart canvas
- Add horizontal time controls bar below chart: step-back, play/pause, step-fwd buttons + range slider + time label
- Slider scrubs by time (frame index), synced bidirectionally with playback
- Play/pause button animates through frames at sweep speed
- Full-width controls filling bottom panel

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): make graphs responsive to resize, remove background from title/controls

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.22-20260603 [version bump]

* perf(ShadeTool): throttle composite hover, debounce time changes, fix graph play interval leak

- Throttle _onCompositeHover with requestAnimationFrame (was triggering
  Zustand store updates + React re-renders on every mousemove)
- Debounce _onTimeChange by 300ms (was firing shade() for all elements
  on every TimeControl tick during animation)
- Fix graph play interval leak: use module-level var cleared on close()
- Add window resize listener + pxIsTools store subscription for
  responsive graph redraw (ResizeObserver alone was insufficient)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): pin horizon/crosshair to sweep center, improve atlas performance

- Horizon profile now uses sweep-time observer center (stored in
  sweepElData[id].sweepCenter) instead of current map center
- Azimuth hover line originates from sweep center position
- Crosshair pins to sweep center on pan (tracks via map 'move' event)
- Crosshair now has 2px black border + stronger shadow for visibility
- buildSweepAtlas: reduced chunk size from 16 to 4 frames, added
  progress reporting ('Building atlas: XX%'), broken assembleAtlas
  into chunked processing (yields to event loop between tiles)
- Toast 'Sweep complete' now shows AFTER atlas is built (was showing
  before, giving false sense of completion)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(ShadeTool): eliminate toDataURL() bottleneck from atlas frame rendering

Root cause: buildSweepAtlas was calling renderResultToTileData() for each
frame, which calls c.toDataURL() per tile (PNG encoding, ~10-20ms each).
With 128 frames × N tiles = thousands of PNG encodes blocking main thread.

Fix: new _renderFrameCanvases() method that only produces canvas objects
(drawImage clone) without any toDataURL() calls. Atlas assembly still calls
toDataURL() once per output tile (unavoidable for GL layer) but that's only
N tiles total instead of N×frames.

Also: crosshair now uses outline (doesn't affect element size) so white
fill is clearly visible with black border.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): accurate progress tracking and trajectory line wrapping

Progress bar now reflects the FULL sweep pipeline:
- 0-50%: shade computation (processChunk)
- 50%: 'Computing heatmap...' (cumulativeVisibility)
- 55-90%: 'Building atlas: X%' (frame rendering)
- 90-100%: 'Building atlas: assembling...' (tile toDataURL)
- Toast only fires at true completion

Previously the bar jumped to 100% after shade computation (fast phase)
then stayed there for 20s during atlas building with no visible progress.

Also: setTimeout(0) yield before cumulativeVisibility so the 'Computing
heatmap...' message actually paints before the synchronous computation.

Horizon chart: break trajectory line at ±180° azimuth boundary to prevent
long wrap-around lines spanning the chart when source crosses South.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(ShadeTool): rewrite atlas build to eliminate intermediate canvases

Previous approach: created 512+ intermediate canvas objects, performed
1024 drawImage operations, then assembled into atlas. Took ~15-20s.

New approach: renders pixel data directly into atlas canvas via putImageData
at the correct frame offset. Eliminates ALL intermediate canvas allocations
and drawImage calls. Benchmarked at ~2.7s for 128 frames x 8 tiles.

Additional optimizations:
- Reuse a single ImageData/buffer per tile (avoids per-frame allocation)
- Pre-compute color values outside the pixel loop (no object alloc per pixel)
- Use bitwise OR for integer division in hot loop
- Reduce processChunk batch size from 16 to 4 for smoother progress updates
- Fix progress text: show 'Computing shade X/128' instead of misleading '100%'

Also fixes horizon chart rendering order: yellow trajectory renders
BEHIND the brown area chart (rgba(90,62,35,0.8)).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): horizon chart polish, TimeUI indicator API, progress rAF fix

- Remove azimuth x-axis labels from horizon chart (cleaner look)
- Add small north arrow indicator at top center of horizon chart
- shadeGraphTimeLabel now shows full ISO time (matching vstSweepFrameLabel)
- Add TimeUI.addIndicator(id, groupId, color, time) / removeIndicator(id, groupId) API
  - Renders vertical colored lines on the TimeUI timeline
  - Re-renders on timeline zoom/pan/redraw
  - CSS: .mmgisTimeUIIndicatorLine styled as absolute-positioned colored lines
- ShadeTool adds red indicator on timeline at current playback time
  - Updated on every frame change via sweepShowAllFrames
  - Removed on destroy via TimeUI.removeIndicator(null, 'shadetool')
- Switch processChunk and buildSweepAtlas yields from setTimeout(fn,0) to
  requestAnimationFrame(fn) so the browser paints between iterations,
  making the progress bar update visibly instead of jumping 0->100%

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: north arrow up, y-axis padding, progress bar lifecycle

- North arrow in horizon chart now points upward (triangle + N label)
- Elevation y-axis label moved slightly for better readability
- Fixed progress bar: onComplete was called before buildSweepAtlas
  started, causing 'Done' state to flash before atlas build. Now
  onComplete is deferred until atlas finishes in playback mode.
- Added pct updates during tile loading (0-5%) and position API (5-15%)
  phases so progress bar moves throughout the entire pipeline.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): throttle sweep progress updates, adjust UI spacing

- Throttle sweepProgressPct Zustand updates to flush at most every 50ms
  so React can actually repaint between requestAnimationFrame callbacks.
  Previously the store was updating ~200x/sec but the ProgressButton
  never re-rendered because React batched all the rapid state changes.

- Elevation axis label: decrease translate x (4px) to give more padding
  between the label and the y-axis tick marks

- North arrow: increase gap between triangle and N text (triangle moved
  6px higher)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): progress bar now updates during sweep

The ProgressButton in ShadeElement reads el.loadingProgress, but
_flushSweepProgress was only writing to the top-level sweepProgressPct
store field (consumed by the unmounted SweepSection component).

Now _flushSweepProgress also updates the active element's
loadingProgress, so the actually-rendered ProgressButton re-renders
with intermediate percentages during the sweep.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): IconTextButton, monotonic progress, single-row time controls

- New design-system IconTextButton (base-ui button with icon + text label)
- Graph buttons now use IconTextButton instead of raw <button> elements
- Progress bar is monotonic: percentage never goes backwards during sweep
- Time controls condensed to single row: [start] → [step|min] → [end]
  with start/end read-only (set via TimeUI), only step editable

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(ShadeTool): match DrawTool header, UI refinements

- Header now uses shared mmgisToolHeader/mmgisToolTitle pattern (40px, matching DrawTool exactly)
- Tool width increased to 300px
- Time row: arrows removed, start/end are plain text (not inputs)
- vstBinaryLegend + vstTime background: var(--color-a-5)
- Graph time controls: play buttons styled like IconButton md (28px, transparent bg, 18px icons)
- shadeGraphTimeLabel font-size: 14px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): div-based multi-source visibility timeline + multi-arc horizon chart

- Replace canvas-based visibility timeline with div-based bars
- Show ALL shade elements with sweep results simultaneously
- Each element gets a labeled row with its own color
- Simplify time labels (no seconds/microseconds, omit year if constant)
- Title changed to 'Visibility Timeline'
- Horizon chart shows trajectory arcs from all linked shade maps
  (same center, time, step) with each element's own color
- Brighten dark element colors for visibility on dark backgrounds

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): fix horizon/visibility errors + sweep progress tracking

- Add missing 'store' variable in _drawHorizonCanvas (ReferenceError)
- Fix visibility timeline accessing elms[0].results instead of elms[0].ed.results
- Track sweeping element ID in _flushSweepProgress so progress updates
  target the correct element, not the (changing) activeElmId
- Reset all elements' loading state when starting a new shadeSweepAll
  to prevent stale progress from a cancelled sweep

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): fix scrub error, horizon fill, and stuck sweep progress

- Fix _scrubFromVisibilityX: access elms[0].ed.results (not elms[0].results)
- Horizon chart: include source trajectory elevations in auto-fit range,
  extend terrain fill to canvas bottom so arcs below horizon are covered
- Reset regenerating/loading state on cancelled sweeps so buttons don't
  stay stuck at partial progress when a new sweep replaces an in-flight one
- Clean up all regenerating elements in cancelSweep and shadeSweepElement

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(ShadeTool): per-element sweep run IDs for simultaneous sweeps

- Replace global _sweepRunId with per-element _sweepRunIds map so
  multiple shade maps can sweep simultaneously without cancelling
  each other
- _flushSweepProgress now takes elmId as first arg to target the
  correct element's loadingProgress
- _highWaterPcts is per-element for monotonic progress tracking
- shadeSweep takes explicit activeElmId parameter instead of reading
  store.activeElmId (which changes during concurrent sweeps)
- shadeSweepAll uses separate _sweepAllRunId for its serialization loop
- cancelSweep increments all per-element run IDs to cancel everything

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): combine horizon + visibility into single panel

- Single 'Charts' toggle button replaces separate Horizon Profile and
  Visibility Timeline buttons
- Both charts render in one combined bottom panel (horizon on top,
  visibility below, shared time controls at bottom)
- Horizon chart min elevation fixed to exactly 5° below the minimum
  terrain horizon point (saves vertical space)
- Trajectory elevations only affect the top bound, not the bottom

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): reconnect time controls to combined graph panel

- _scrubToFrame now redraws both horizon canvas and visibility timeline
  after updating the store (was only setting store + callback)
- updatePlaybackFrame no longer checks _activeView type — always
  redraws both charts since they're combined in one panel
- Fixes play/step/slider not updating charts after panel merge

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): source section closed by default, +New cycles sources

- Source collapsible section starts closed (was open)
- +New button cycles through non-custom source entities based on the
  last element's source (e.g. Sun → Moon → Sun → Moon...)
- Custom source is excluded from the cycle

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): visibility timeline uses actual visibilityPct, not horizon re-computation

The visibility timeline was re-computing visibility by comparing source
elevation against the interpolated horizon profile. This disagreed with
the actual shade computation (visibilityPct) because the horizon profile
is a simplified 1D ray-cast while the shade grid is a full terrain shadow
computation.

Now uses visibilityPct > 0 (the actual shade API result) to determine
visible vs occluded segments.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): add visibility timeline legend + use center-cell visibility

- Add 'Visibility Timeline' header bar with horizontal legend showing
  Visible/Occluded color swatches at the top of the visibility panel
- Store centerVisible (observer grid center cell) in sweep results
  instead of using grid-wide visibilityPct > 0 which was too coarse
- Falls back to visibilityPct for backward compatibility with older data

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): move legend to panel top, invert vis chart colors

- Legend bar at top-left of entire bottom panel (Visible/Terrain/Shaded)
  applies to both horizon chart and visibility timeline
- Visibility timeline: shaded segments use element color, visible = white
- Removed vis-specific header/legend (now redundant)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): revert vis colors, remove legend, add occluded label, fix slider alignment

- Reverted vis chart to original colors: element color = visible, gray = occluded
- Removed the panel-level graph legend
- Added 'occluded' suffix text to vis label (lighter weight/opacity)
- Fixed time slider at start/end of vis chart: use playIndex/(frameCount-1)
  so slider reaches exact bar edges at first and last frames
- Widened vis label column to accommodate 'occluded' suffix

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis label text to 'Occultations', full opacity, 12px font

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): don't regen static shade when time slider changes during playback

Skip composite/playback elements in _onTimeChange — they should only
scrub through existing sweep frames, not trigger a new shade computation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): remove horizon canvas right margin, shift elev label 15px right

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): invert vis chart colors — colored = occluded, gray = visible

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis chart uses horizon profile for terrain-aware visibility

Uses _interpolateHorizon(profile, azimuth) to compare source elevation
against the terrain horizon mask — the same data drawn in the horizon
chart. Source is visible only when elevation > terrain at that azimuth.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): gradient transitions at vis/occ boundaries in occultation chart

At each visible↔occluded transition, segments now use a CSS
linear-gradient that fades over ~20% of the segment width instead
of a hard color edge.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis chart correct on first open + fix double gradient

- Redraw visibility timeline after horizon profile fetch completes
  (was rendering with null profile on first open → all occluded)
- Gradient only on trailing edge of each segment (not both sides)
  to eliminate the double-gradient artifact at boundaries

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): only show results section after sweep completes

Added !el?.regenerating guard to the auto-open effect so the
results/run section stays hidden during the sweep and only opens
once regenerating is false (sweep fully complete).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): center-align vis timeline tick marks with their text labels

Use transform: translateX(-50%) on the tick container and
align-items: center so both the tick line and text are centered
at the same position.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): hide results section during sweep, only show when complete

- Initialize resultsOpen to false for non-static modes
- Add !el.regenerating guard to the render condition so even if
  resultsOpen is true, the content is hidden while regenerating
- Auto-open effect still fires when regenerating turns false

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): make vis timeline time ticks responsive to label width

Instead of hardcoded labelCol/margin-left values, measure the actual
.shadeVisBar element's left offset at render time. The time labels
container margin and scrub handler now both derive their position
from the real bar bounds, so they stay aligned regardless of label
text length.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): add fast-forward button to graph time controls

New fast-forward button (mdi-fast-forward) between play/pause and
step-forward. Toggles 4x playback speed (interval/4, min 50ms).
Active state highlighted with shadeGraphPlayBtnActive class.
Clicking play/pause while fast is active resets to normal speed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): layer z-ordering after sweeps + vis chart time label timezone

- Re-apply sweepCardOrder z-indices after sweepShowAllFrames,
  sweepShowComposite, and showSweepLayers so the first element
  stays on top instead of the last-swept one
- Fix _formatSmartTimeLabel using local timezone for month but UTC
  for day/hour/min — add timeZone:'UTC' to toLocaleString so
  2024-01-01T00:00:00Z shows 'Jan 1' not 'Dec 1'

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): charts survive window resize without going fully occluded

Root cause: window resize fires Leaflet moveend → _onPanEnd →
invalidateHorizonCache() nulls _horizonCache. The resize redraw
timeout then finds no profile, so the vis timeline defaults all
frames to occluded and the horizon chart is skipped.

Fix: _scheduleRedraw now calls fetchAndDrawHorizon() which re-uses
the cache when the position hasn't changed or re-fetches when it
has. Also added drawVisibilityTimeline in the cache-hit path of
fetchAndDrawHorizon so both charts always update together.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): remove link button, center playback controls, filter charts by center+time

- Removed playbackLinked toggle and all related state (localPlayIndex,
  handleToggleLinked, vstLink styles). All elements now share the
  global sweepPlayIndex.
- Centered playback controls in the sweep row.
- Charts (horizon + visibility) now only include shade maps whose
  sweep center, start time, step, and frame count match the element
  whose Charts button was clicked.
- Added info banner at top-left of bottom panel when shade maps are
  excluded (shows count and reason).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): colored azimuth lines on map while graphs open; fix stale slider on re-sweep

- Added persistent colored dashed azimuth lines on the map for each
  shade element while the charts panel is open. Each line uses the
  element's color and updates on every frame change (scrub, play,
  step). Lines are removed when graphs close or ShadeTool is destroyed.
- Fixed the time slider retaining the old frame count after a new sweep
  with fewer steps: updatePlaybackFrame now re-syncs slider.max from
  the current sweep results on every call.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): horizon profile skips near-observer DEM pixels and accounts for curvature

- HorizonProfile.py now accepts minSkipRadius (meters) and
  planetRadius (meters) parameters. Rays skip DEM pixels within
  minSkipRadius of the observer to avoid blocky near-field artifacts.
  When planetRadius > 0, applies curvature drop (d²/2R) so distant
  terrain dips below the flat-earth projection.
- Frontend sends minSkipRadius=50m and planetRadius from
  F_.radiusOfPlanetMajor when vars.curvature config is enabled
  (defaults to true).
- Backend route passes the two new params through to the Python script.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): trim seconds from vstTimeReadonly display

Display shows e.g. '2024-01-01T00:00Z' instead of '2024-01-01T00:00:00Z'.
Full timestamp preserved in title tooltip. Stored value unchanged.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): Charts button height 28px, font-size 12px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vstTimeReadonly styling, hide empty playback section on mode switch

- vstTimeReadonly: font-size 11px, opacity 0.8, first child text-align right
- Close results section on mode change so switching composite→playback
  doesn't show an empty run section
- Auto-open now checks mode-specific data (grids for playback, atlas
  for composite) instead of just results existence

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): prevent empty playback run section after composite→playback switch

Remove shadeMode from auto-open effect deps so that merely switching
modes doesn't re-open the results section with stale data from the
previous mode. The effect now only fires when actual sweep data changes
(results, grids, atlas, regenerating state), ensuring the section
stays closed after a mode switch until a new sweep completes.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): change default icon to mdi-brightness-4

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): Charts button color var(--color-a7)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): theme-aware canvases + composite auto-open results

- Az/El indicators, sky dome, horizon chart: fixed dark (#1a1e22)
  backgrounds so they remain legible in light theme
- Vis time tick lines/text: use var(--color-a4) instead of hardcoded
  white rgba
- Composite sweep auto-open: check el.lastResultGrid (set by composite
  path) instead of ed.atlas (only set by playback sweep)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(TimeUI): mmgisTimeUITimelineExtent z-index, pointer-events, opacity

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): legend hover indicator and colorstop labels always use light text

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): lighten az/el canvas backgrounds, theme-aware sky dome labels

- Static az/el: dark fill (#2a3038) + light stroke/axis lines (was white 0.1 fill + black stroke)
- Playback az/el: lightened from #1a1e22 to #2a3038
- Sky dome N/S/E/W labels: read --color-f from CSS so they're dark in light mode

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): horizon profile fully theme-responsive

- Background reads --color-a (matches page bg in both themes)
- Grid lines use black in light mode, white in dark mode
- Tick labels, axis title use --color-a4 (muted text)
- North arrow/label uses --color-f (text color)
- Terrain fill lighter/more transparent in light mode
- 0° line adapts to theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis chart visible segments near-white in light mode

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis chart tick marks now span full time range

Position ticks using i/(N-1) instead of i/N so the last tick
lands at 100%. Always include the final time label. Step size
calculated from (N-1)/(maxLabels-1) to evenly distribute ticks
across the full range.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis chart ticks evenly spaced 0%-100%, no drift

Ticks are now placed at evenly-spaced visual positions and each
maps back to the nearest frame index for its label. This avoids
the gradual drift caused by ticks and bar segments using different
coordinate systems (i/(N-1) vs i/N).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): all canvas circle backgrounds to #2c2f30

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): canvas circle backgrounds to black

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): canvas circle backgrounds to #0f1010

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(security): add path validation + numeric checks to /gethorizonprofile

- Validate required fields (path, lat, lng)
- Full URL decoding loop to catch encoded traversal
- Restrict paths to /Missions/ (cross-mission ../ allowed)
- Resolved path must stay within Missions directory
- All numeric params validated as finite numbers
- numAzimuths capped at 3600, maxRadius at 100000 (DoS prevention)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs+tests(ShadeTool): E2E tests for horizon profile API, update Shade docs

- E2E tests: input validation, path traversal protection, numeric checks
- Docs: shade modes (static/composite/playback), charts panel (horizon
  profile + occultation timeline), sky dome, azimuth lines, time controls

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(utils): extract shared validateMissionsPath helper

- New validateMissionsPath() handles URL decode loop + /Missions check +
  path.resolve guard in one place
- queryTilesetTimesDir and /gethorizonprofile both use the shared helper
- Fix E2E test: replace Infinity test (unrepresentable in JSON) with NaN string

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(tests): horizon profile E2E tests skip gracefully in AUTH=local mode

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(ShadeTool): canvas circle backgrounds to #575d60

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(ShadeTool): canvas circle backgrounds to #3a3e40

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(utils): validateMissionsPath accepts paths without leading slash

Frontend sends 'Missions/...' (no leading slash) via L_.missionPath.
Normalise by prepending '/' before the prefix check.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): pass resolved path to HorizonProfile.py + add tippy tooltips

- gethorizonprofile now passes pathResult.resolved (full filesystem path)
  instead of pathResult.decoded (/Missions/...) to the Python script
- Added tippy tooltips: 'Start Time', 'End Time' on vstTimeReadonly spans,
  and step size description on the InputWithUnit

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(ShadeTool): blue-themed Tooltip for time fields + close button shifted left 6px

- Replaced raw tippy refs with design-system <Tooltip> (blue theme)
- Close button in header shifted left 6px via margin-right: -6px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(security): append trailing slash to startsWith check in validateMissionsPath

Prevents /MissionsBackup/... from passing validation. Matches the
pattern already used in scripts/middleware.js (isPathInsideRoot).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(HorizonProfile): bounds-check observer pixel before array access

Return flat profile when observer falls outside the DEM read region
instead of crashing with IndexError.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): export improvements, color ramps, no-data fix, thicker azimuth lines

1. Export TXT: add bounding box in meters + projection info to grid export
2. Export CSV: row-per-pixel format (entity,time_range/time,lat,lng,percent_visible)
   instead of one row per frame — uses heatmap data for both composite and playback
3. Color ramps: add two element-color-based ramps (Fade: transparent→color→transparent,
   Edges: color→transparent→color) that use the shademap's user-set color
4. No-data: hide faint red coloring (val===9) — render as fully transparent instead
5. Azimuth lines: increase stroke-width from 2 to 3.5 for better visibility on map

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.23-20260604 [version bump]

* fix(ShadeTool): export TXT bounding box uses CRS project() for projected meters

Use window.mmgisglobal.customCRS.project() to convert SW/NE corners
to proper projected coordinates instead of simple degree-to-meter
conversion. Also includes proj4 string and cell size in projected units.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): color ramps use proper RGBA transparency, 3 stops each

The new ramps (Fade: transparent→color→transparent, Edges:
color→transparent→color) now use 3 RGBA color stops with the 4th
component controlling alpha. evalColor interpolates alpha when present.
Renderer and legend both read the interpolated alpha for these ramps.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): include observer height in horizon cache key

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ColorRampPicker): rewrite dropdown to use continuous gradient with RGBA alpha support

- Replace discrete block rendering with continuous linear-gradient matching
  the legend's interpolation approach
- Shadow ramp dropdown now matches legend direction (opaque→transparent)
- New alpha-based ramps (_tct, _ctc) properly show transparency over
  checkerboard background in both the trigger and popup
- All ramps with hasAlpha flag get a checkerboard underlay
- Standard colormaps (viridis, plasma, etc.) render as smooth gradients

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): split composite CSV time range fields

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): refresh color-based sweeps after color changes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(Sightline): rename ShadeTool → SightlineTool, invert rendering semantics

- Rename entire Shade tool directory to Sightline (git mv)
- Rename all files: ShadeTool.js → SightlineTool.js, etc.
- Rename store: useShadeStore → useSightlineStore
- Rename CSS classes: .shade* → .sightline*, #shadeTool → #sightlineTool
- Rename internal functions: shade() → sightline(), shadeSweep → sightlineSweep
- Rename default color ramp: 'shadow' → 'sightline'
- Update config.json: name 'Shade' → 'Sightline', paths updated
- Update blueprint reference mission config
- Update all test files and fixtures
- Update docs (Shade.md → Sightline.md, SPICE docs)
- Update imports in tools.js, AnalysisTool comment, Globe_ comments

Rendering inversion (done in prior commit, preserved here):
- store.js: invert default 1 → 0 (visible = prominent color)
- Composite heatmap: alpha = alphaFrac (not 1-alphaFrac)
- Vis chart: colored bars = visible (not occluded)
- Color ramp: alpha = t (not 1-t)
- Legend: isSightlineRamp uses alphaFrac directly

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.24-20260606 [version bump]

* fix(SightlineTool): complete rename cleanup, brighter colors, fix Devin Review findings

- Fix tool title 'Shade' → 'Sightline' in panel header
- Rename help file ShadeTool.md → SightlineTool.md, update all content
- Fix default element name 'Shade N' → 'Sightline N'
- Brighter default MULTI_SOURCE_COLORS (amber, coral, blue, green, pink, etc.)
- Fix SweepCard stale 'shadow' colorRamp fallback → 'sightline' (Devin Review)
- Fix ShaderTool_Algorithm import alias → SightlineTool_Algorithm
- Rename internal functions: computeShade → computeSightline,
  showShademapLayers → showSightlinemapLayers,
  clearAllShadeLayers → clearAllSightlineLayers,
  reorderShadeLayers → reorderSightlineLayers
- Fix SightlinePanel calling stale reorderShadeLayers
- Update export labels: 'Shade Map' → 'Sightline Map', 'Shade Grid' → 'Sightline Grid'
- Update progress/toast strings from 'Shade' to 'Sightline'
- Update SPICE docs reference
- Update help file section headers and descriptions

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): default sightline color ramp uses element color instead of black

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): remove vstBinaryLegend, default opacity 50%

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): crosshair redesign — unfilled circle with N/S/E/W tick lines

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.25-20260608 [version bump]

* fix(SightlineTool): full-size pixelated PNG export, mode-aware CSV/TXT, rename to Results

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): entity lowercase, ISO UTC times in exports, static TXT header fix, smaller crosshair

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(SightlineTool): playback export as animated GIF with basemap overlay

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): header close button margin-right 6px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF export - capture full map container, guard against 0-size canvas

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF export - hide UI controls, add timestamp/progress, lower resolution

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF export - 720px res, UI progress indicator, remove in-frame progress bar

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF progress reaches 100% only after encoding completes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): rewrite CSV exports per mode, remove TXT for playback

- Static: entity,time,lat,lng,visible (single time, binary)
- Playback: entity,time,lat,lng,visible (per-pixel per-frame, each frame's timestamp)
- Composite: entity,start_time,end_time,lat,lng,percent_visible (time range, heatmap)
- Remove TXT grid export option for playback mode

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): reset export dropdown to first item on mode change

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(blueprints): add SightlineTool to Lunar South Pole + new Mars reference mission

- Add time section and SightlineTool config to Lunar South Pole mission
- Create new Reference-Mission-Mars with LayersTool + SightlineTool (Sol observers)
- Reference Mission already has SightlineTool on development branch

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.26-20260608 [version bump]

* feat: register Mars variant in reference mission registries

Add Mars entry to REFERENCE_MISSION_VARIANTS in both:
- API/Backend/Utils/missionTemplates.js (backend)
- configure/src/.../NewMissionModal.js (frontend dropdown)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add lunar ref mission dem data

* Add lunar ref mission dem data 2

* fix: sample bbox perimeter for tile bounds in polar projections

The bounding box clamping in SightlineTool and ViewshedTool
updateDesiredTiles() projected only two corner points of the bbox to
compute tile bounds. In polar stereographic projections (e.g. Lunar
South Pole IAU2000:30120), opposite corners of a lat/lon bbox can map
to the same pixel-space point, producing a degenerate or inverted tile
range — resulting in zero tiles fetched and no sightmap/viewshed
overlay rendered.

Fix: sample 9 evenly-spaced points along each of the four bbox edges
(36 points total) and take the pixel-space envelope. This correctly
handles any map projection.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: projection-aware azimuth lines and source positioning for polar CRS

- locateSource: skip wrapping for custom (non-wrapping) projections so
  distant sources like Sun produce nearly-parallel rays instead of being
  folded into the grid as a nearby point source
- AZ hover line: rotate by local north offset derived from the projection
  so the bearing line points in the correct geographic direction
- Source azimuth lines: same north-offset correction
- Add _localNorthAngle() helper that computes screen-space north direction
  at any lat/lng by projecting a small latitude offset

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): skip observer-centric curvature for distant sources

When the ray source (e.g. Sun) is far outside the DEM grid, the
observer-centric curvature correction in curveData() creates a
circular visibility artifact — the terrain bowl centered on the
observer falsely hides all cells beyond ~350 km.

Skip the curvature correction when dataSource is more than one
grid-width outside the data bounds.  The shadow-plane propagation
already handles parallel-ray geometry correctly on the flat grid.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add backend sightmap endpoint with per-cell ray-march

- New Python endpoint /api/sightmap computes solar illumination grids
  server-side using SPICE for Sun position + DEM ray-marching
- Frontend calls backend instead of fetching tiles + JS shadow-plane algo
- USE_BACKEND_SIGHTMAP const switch to revert to old JS algorithm
- Returns both geographic and projected bounds for polar stereo CRS
- Sightmap overlay uses image-rendering: pixelated for crisp grid cells
- Auto-detects PROJ_DATA path in conda/mamba environments

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.27-20260609 [version bump]

* refactor: consolidate sightmap to backend-only, remove old JS algorithm

- Remove USE_BACKEND_SIGHTMAP flag and all old JS shadow-plane code
- Static path: single sightmap API call (no getbands/ll2aerll)
- Sweep path: batch sightmap API call with times array
- Remove tile-based rendering (makeDataLayer, renderResultToTileData,
  atlas shader, _renderFrameCanvases, SightlineTool_Manager import)
- Rewrite renderHeatmapToMap for imageOverlay (no tile layer)
- Rewrite buildSweepAtlas to pre-render frames as dataURL images
- Rewrite sweepShowFrame to swap imageOverlay URL
- Update export functions for backend grid data structure
- Backend sightmap.py: batch mode (compute_sightmap_batch),
  extracted _ray_march_grid and _compute_bounds helpers
- Express route: dynamic timeout for batch (N*30s, max 30min)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: vectorize ray-march with NumPy + coarse Sun az/el interpolation

Replace pure-Python nested loops in _ray_march_grid() with numpy array
operations.  All output cells are now processed simultaneously at each
march step via broadcasting and fancy indexing.

Optimizations:
1. Vectorized ray march (#1): ~60x speedup on 259×259 grid (47s → 0.8s)
2. Coarse Sun az/el subgrid (#4): compute SPICE-equivalent az/el on a
   10×10 subgrid and bilinearly interpolate to all output cells, reducing
   per-cell coordinate transforms from 67K to ~400.

Helper functions added:
- _vectorized_is_nodata: array-based nodata detection
- _pixels_to_geo_batch: batch pixel→geographic via GDAL CoordinateTransformation
- _sun_azel_batch: pure-numpy geodetic Sun az/el (replaces spiceypy.georec per cell)
- _bilinear_interp_2d: pure-numpy 2D bilinear interpolation (no scipy needed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: store raeRaw/raeAllResults so az/el indicator canvases redraw after mount

The SightlineResults.jsx useEffect triggers on el.raeRaw to redraw
indicator canvases. The static callback was calling updateRAEIndicators
directly (before React mounted the canvases) and only setting raeResults
but not raeRaw/raeAllResults, so the useEffect never fired.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: playback skydome/az-el blocked by stale atlas guard + pixelated CSS selector

- SightlineElement.jsx: replace ed?.atlas checks with ed?.frameImages
  (atlas was the old WebGL texture atlas; refactored code uses frameImages)
- SightlineTool.css: add img.sightmap-pixelated selector (Leaflet applies
  className directly to the <img>, not a wrapper div)
- python-environment.yml: add numba>=0.60.0 for JIT optimization

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: Numba JIT + adaptive march + early cutoff + multiprocessing

sightmap.py performance optimizations on the vectorized ray-march:

1. Numba JIT (@numba.njit): compile inner march loop to native code
   - 259x259 grid: 0.67s cold / 0.12s warm (was 0.79s numpy-only)
2. Adaptive march step: 4x step when margin > 5°, 2x when > 2°
   - Reduces iterations for clearly-illuminated cells
3. Early cutoff: max shadow distance = MAX_TERRAIN_H / tan(el)
   - Skips pointless marching when Sun is high
4. Multiprocessing for batch: Pool(ncpu) across timestamps
   - 12 frames in 0.62s (52ms/frame) vs sequential

Overall: 47.1s → 0.12s per grid = ~380x speedup (warm cache)
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: use Pool initializer for multiprocessing (Windows spawn compat)

On Windows/macOS, multiprocessing uses 'spawn' instead of 'fork',
so module-level globals aren't inherited by worker processes.
Fix: pass shared dict via Pool(initializer=_init_pool_worker, initargs=...)
so each worker sets _mp_shared in its own module namespace.

Tested: 72 timestamps in 1.28s (18ms/frame) on 100x100 grid.
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: five bugs — az offset, playback opacity, progress indicator, mode switch, hover crash

1. sightmap.py: _compute_sun_grid used dem_rows instead of dem_cols for
   column pixel clamping → coarse subgrid positions were wrong → az/el
   off by ~30-45° on non-square DEMs

2. Playback/sweep overlay now inherits el.opacity as initial value
   instead of hardcoded 1.0. Default sweep opacity changed to null;
   applySweepOpacity falls back to el.opacity when ed.opacity unset

3. ProgressButton: added indeterminate mode (sliding bar animation)
   that activates automatically when loading=true and progress<=0.
   Static sightmap generation shows indeterminate; sweep shows %

4. switchElementMode: switching back to static now re-renders the
   cached lastData/lastResultGrid or marks changed=true for auto-regen

5. _onCompositeHover: guard against missing topLeftTile (backend
   sightmap response doesn't include tile-based fields)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct sightmap ray direction for polar stereographic projections

_compute_directions had two bugs in the projected CRS branch:

1. Missing grid convergence rotation: geographic azimuth was used
   directly in pixel space without rotating by the convergence angle
   (longitude for polar stereographic). This caused ~30-45° offset
   at the observer's longitude.

2. Inverted dy sign: cos(az) was used for dy when gt[5]<0, but
   increasing pixel-y = decreasing northing, so the formula must
   negate cos(az) — matching the geographic branch convention.

Also fix _showAzimuthLine/_updateSourceAzimuthLines: the fallback
path (no sweepCenter or indicatorLastDragPoint) now computes
centerLatLng from the map container center so _localNorthAngle
always receives a valid position for the convergence rotation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: add extensive Playwright tests for sightmap API endpoint

Covers:
- Input validation (missing fields, non-finite numerics, NaN, Infinity)
- Path traversal protection on DEM path
- Single-timestamp sightmap computation (structure, grid values, az/el plausibility)
- Batch multi-timestamp computation (results array, az variation)
- Custom Az/El source (el=0 all shadow, el=90 all visible)
- Error handling (invalid SPICE target, nonexistent DEM)
- Consistency (determinism, different times produce different grids)
- Observer height offset comparison
- maxOutputDim clamping
- Projected bounds for polar stereographic CRS

All DEM-dependent tests gracefully skip when Lunar South Pole
mission is not available. AUTH=local mode returns HTML instead
of JSON — tests detect and skip.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): use observer position for azimuth indicator north offset

The azimuth indicator line was computing _localNorthAngle from the map
center (fallback when sweepCenter is unset), which drifts as the user
pans and may be at the south pole where longitude is ~0. The sightmap
shadows are computed from the actual observer position with correct
per-cell convergence, creating a mismatch.

Fix: store the observer lat/lng as sweepCenter in sweepElData when the
static sightmap completes (same as sweep mode already does). This
ensures _showAzimuthLine, _updateSourceAzimuthLines, the crosshair
placement, and the horizon profile all use the correct observer position
for the north angle calculation.

Also add indicatorLastDragPoint fallback to _updateSourceAzimuthLines
(was missing, unlike _showAzimuthLine which already had it).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightmap): correct azimuth CW/CCW convention in _sun_azel_batch

The east vector was computed as normal × north (= Up × N = -East = West),
causing atan2(dot(sun, west), dot(sun, north)) to return counter-clockwise
azimuths. This mirrored the result: geographic 240° CW reported as 120°.

Fix: use north × normal (= N × Up = East) per right-hand rule, matching
SPICE's azccw=False (clockwise from north) convention.

Verified: batch az now matches SPICE exactly (226.2493° vs 226.2493°).
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): use fixed-width fade gradient for visibility segments

The gradient on segment transitions was 30% of the segment width,
making wide segments have long fades and narrow segments short fades.
Now uses a fixed 2% of the total bar width for all transitions,
producing uniform fade-in and fade-out lengths regardless of segment size.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): wire resolution setting to maxOutputDim + gifshot fixes

Resolution dropdown (Low/Med/High/Ultra) now controls the sightmap
grid size sent to the backend:
  Static:  100 / 200 / 400 / 800 px
  Sweep:    50 / 100 / 200 / 400 px

Also:
- Add willReadFrequently to GIF export canvas contexts (suppresses
  Chrome warning about slow getImageData readback)
- Add gifshot progressCallback to update export progress during the
  GIF encoding phase (90→100%) instead of jumping at the end

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): remove dead SightlineTool_Manager and Layer specific DEMs config

SightlineTool_Manager.js was part of the old JS tile-fetching algorithm
(now replaced by the backend sightmap.py endpoint). Nothing imports it.

Remove:
- SightlineTool_Manager.js
- 'Layer specific DEMs' config section (variables.data with demtileurl,
  minZoom, maxNativeZoom, boundingBox) from config.json and all blueprint
  mission configs
- vars.data validation in initialize() — replaced with vars.dem check

The Viewshed tool's separate Manager and its layer-level demtileurl
references are unaffected.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): downsample large DEMs at read time via GDAL decimation

High-res DEMs (e.g. 100m vs 4000m) were read fully into memory even when
the output grid was small, causing:
1. Slow computation (full array I/O + march through many more pixels)
2. Windows pickle truncation on multiprocessing (huge arrays exceed
   pickle buffer limits when serialized to spawn'd worker processes)

Fix: open_dem() now accepts max_working_dim and uses GDAL's ReadAsArray
with buf_xsize/buf_ysize for server-side bilinear decimation. The
geotransform is adjusted to match the resampled pixel grid. Working dim
is set to max(max_output_dim * 4, 1000) — 4x oversampling for terrain
detail in the ray march, capped at native resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): GDAL overview bands, DEM caching, Numba warmup, reduce working_dim

Three optimizations to cut sightmap generation time on large DEMs:

1. GDAL overview bands: If the DEM has pre-computed overviews (COGs),
   read from the overview band directly instead of decimating the full
   raster in memory. Falls back to ReadAsArray(buf_xsize/ysize) for
   DEMs without overviews.

2. DEM caching: Module-level cache keyed by (path, working_dim) avoids
   re-reading the same DEM within a batch run. LRU eviction at 4 entries.

3. Numba JIT warmup: Trigger compilation at module import with a tiny
   2x2 dummy array so the ~5-10s first-compilation cost is paid during
   startup, not during the actual sightmap computation.

4. Reduce working_dim from 4x to 2x output grid (min 500px instead of
   1000px). Halves the march distance and array sizes while maintaining
   sufficient terrain detail for shadow boundary accuracy.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: add timing instrumentation to sightmap.py

Temporary timing logs to stderr at each phase:
- imports, numba_warmup, load_kernels, spice_azel, unload_kernels
- open_dem (with working_dim and actual array size)
- _precompute_grid, _compute_sun_grid, _compute_directions
- _numba_march, total

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: pipe sightmap stderr to Node console for timing visibility

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: include timing data in sightmap JSON response body

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): disk-based .npy cache for decimated DEMs

The timing data showed open_dem takes 12.8s (GDAL reading/decimating a
large DEM) while the actual Numba march takes only 0.065s.  Since each
sightmap call spawns a new Python process, the in-memory cache is lost.

Fix: after the first GDAL decimate, save the resulting numpy array and
geotransform metadata to .npy/.json files next to the source DEM.
Subsequent process invocations load the pre-decimated array via np.load
(~0.01s).  Cache is invalidated if the source DEM file is newer.

Expected improvement: first run ~14s (unchanged), second+ runs ~2s.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): require COG format for large DEMs, remove npy cache

Replace the disk-based .npy cache with a simpler approach: require the
DEM to be a Cloud Optimized GeoTIFF (COG) when decimation is needed.
COGs have internal tiled layout + overview pyramids so GDAL can read at
any target resolution by seeking to the right bytes — no full-file scan.

If the DEM is not a COG and is large enough to need decimation, throw a
clear error with the gdal_translate command to convert it.

Small DEMs that fit within max_working_dim are read directly regardless
of format (no performance issue at small sizes).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: pass through Python error messages to frontend on sightmap failure

When sightmap.py exits with code!=0, the Node handler was returning a
generic 'sightmap computation failed' message. Now it tries to parse
the structured JSON error from stdout first, so the actual error
(e.g. 'DEM is not a COG') reaches the frontend.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sanitize COG error message — no paths or tracebacks in response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove traceback from JSON error response, log to stderr only

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: show actual sightmap error message in toast

- calls.api error callback now parses JSON response body from jqXHR
- SightlineTool error handlers display server error message (e.g. COG)
- No traceback or paths in error responses (security)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove duplicate 'sightmap error' prefix from Python error messages

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): temporarily disable multiprocessing for batch mode

Run all timestamps sequentially in the main process so the DEM stays
in memory and avoids pickle serialization overhead per worker.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightmap): remove multiprocessing and Resolution UI option

- Remove all multiprocessing infrastructure (pool, workers, shared state)
- Batch timestamps run sequentially in the main process
- Remove Resolution dropdown from UI, default to ultra (800px static, 400px sweep)
- Always auto-regenerate on map move (no resolution-gated cutoff)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: temporarily disable Numba JIT for benchmarking comparison

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: re-enable Numba JIT after benchmarking (saves ~6s per frame)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: re-add timing instrumentation to sightmap response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: compute grid convergence analytically, skip GDAL TransformPoints

_compute_directions was calling _pixels_to_geo_batch on all 940K cells
(969x969 grid) to get each cell's longitude for the convergence angle.
This took ~1s (29% of total).

Now computes convergence directly from projected coordinates using
atan2(x - false_easting, sign*(y - false_northing)) — pure numpy
math, no GDAL coordinate transform. Expected: ~0.01s.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: skip kernel unloading + increase coarse subgrid step to 50

- Remove spiceypy.unload() calls — process exits immediately after
  response so OS cleanup is sufficient. Saves ~0.235s.
- Increase COARSE_AZEL_STEP from 10 to 50 — reduces coarse subgrid
  points from ~9400 to ~400 for sun az/el interpolation. Sun position
  varies slowly across the DEM so fewer sample points suffice.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap.py

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: interpolate sun azimuth in sin/cos space to prevent wrap artifacts

When coarse subgrid has azimuth values near the 360°/0° boundary
(e.g. 355° and 5°), linear interpolation produces ~180° — completely
wrong direction that flips shadows. Now interpolates sin(az) and
cos(az) separately, then recovers the angle via atan2. This handles
the circular wrap correctly.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: viewport-aware sightmap — clip DEM read to visible area at native resolution

Frontend sends current map viewport bounds (projected coords) with each
static sightmap request. Backend clips the DEM read to the viewport
intersection, reading at native resolution (capped at maxOutputDim).

When zoomed in, this means the sightmap covers just the visible area
but at much higher resolution than the full-DEM downsampled version.
When zoomed out, viewport encompasses the full DEM and behavior is
unchanged.

For a 30993x30993 DEM zoomed to 1/10th coverage, instead of reading
the full raster decimated to 1600x1600, we read only ~3000x3000 native
pixels for the visible window — higher res and faster.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: compute viewport projected bounds from container corners, not lat/lng bbox

In polar stereographic CRS, the lat/lng bounding box from
getBounds() maps to a distorted region in projected space.
Now samples all 4 container pixel corners, projects each through
the CRS, and takes the envelope for correct projected bounds.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: position sightmap overlay using projected NW/SE corners directly

In polar/rotated CRS, L.latLngBounds normalises by min/max lat/lng,
which shuffles corners — getNorthWest() and getSouthEast() return
points that don't correspond to the projected rectangle's NW and SE.
The overlay is then mispositioned and stretched.

New _projImageOverlay() helper overrides the overlay's _reset method
to compute pixel position from the projected NW (xmin, ymax) and
SE (xmax, ymin) corners via latLngToLayerPoint, bypassing the
normalisation entirely.  Applied to all three overlay creation paths:
static sightmap, heatmap, and sweep frame.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: override _animateZoom on projected overlay to prevent zoom jump

The default _animateZoom reads the normalised L.latLngBounds which
has wrong corners in polar CRS, causing the overlay to briefly jump
to the top-left during zoom transitions.  Now _animateZoom uses the
projected NW corner via _latLngToNewLayerPoint for correct animation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: wire viewport bounds through batch/sweep mode

Frontend sweep call now sends viewportBounds.  Backend
compute_sightmap_batch accepts and passes viewport_bounds
to open_dem so playback also clips to the visible DEM region
at native resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: equalize sweep/static resolution + show 'Sweeping' on progress button

- _resolutionToMaxDim now returns 800 for both modes (was 400 for sweep)
- ProgressButton label shows children text alongside percentage
- SightlineElement shows 'Sweeping'/'Generating' while loading

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: actually commit _resolutionToMaxDim change to equalize sweep/static res

Was missed from the previous commit — sweep was still getting 400 instead of 800.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: re-fetch horizon profile after pan so charts stay in sync

When the user panned, invalidateHorizonCache set _horizonCache to null
but never triggered a re-fetch.  Subsequent scrub or playback frame
changes found the cache empty and either skipped the horizon redraw or
drew the visibility timeline with no profile (marking everything not
visible).

- _onPanEnd now calls invalidateAndRefetch() which re-fetches the
  horizon profile if the graph panel is open.
- _scrubToFrame and updatePlaybackFrame fall back to fetchAndDrawHorizon
  when the cache is null instead of drawing with missing data.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile azimuth for projected CRS (polar stereo)

HorizonProfile.py was marching along pixel/raster-space directions,
treating pixel-up as north.  In polar stereographic the grid north
axis is rotated from true north by the grid convergence angle, so the
profile azimuths were offset and the terrain silhouette appeared
rotated in the chart.

Added _grid_convergence() which computes the convergence at the
observer's projected coordinates via atan2(x - FE, -(y - FN)).  Each
geographic azimuth is now rotated by the convergence before marching
in pixel space, making the profile azimuths true geographic azimuths.
No change for geographic (unprojected) CRS.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile convergence formula for polar stereo

Two bugs in the previous convergence fix:

1. _grid_convergence used atan2(x, -y) unconditionally, but for
   south-pole stereo the sign should be +1 (north is away from
   pole = positive y). Now uses north_sign = -1 for north-pole,
   +1 for south-pole, matching sightmap.py exactly.

2. The convergence was subtracted (geo_az - convergence) when it
   should be added (geo_az + convergence), matching sightmap.py's
   convention where convergence rotates from grid north to true
   north.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: use geodesic destination for azimuth lines instead of angle rotation

Replace the _localNorthAngle + screen-space rotation approach with a
direct forward geodesic method: compute a destination lat/lng 1° along
the desired azimuth, project it through Leaflet, and draw the line.

This avoids potential compound angle errors and works correctly for
any CRS because it uses Leaflet's own projection pipeline end-to-end
rather than computing a screen-space north-offset angle and rotating.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: convert reference mission DEMs to Cloud Optimized GeoTIFF (COG)

Both the Earth (USGS SF Hill) and Lunar South Pole (LRO LOLA 4000m)
DEMs are now tiled COGs with deflate compression and overviews.
This ensures consistent behavior with the sightmap COG requirement
and enables fast overview-based reads at any resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add lunar LSMT to chronice, fix TimeUI indicator cleanup and observer time sync

- chronice.py: add lunar LSMT support using SPICE et2lst with observer
  longitude; format: LDAY-NNNNNLHH:MM:SS; reverse conversion via iterative
  refinement
- utils.js: pass optional lng param through to chronice.py
- SightlineTool.js: remove TimeUI indicator on mode switch, cancel sweep,
  resweep start, and pan-end; pass lng from observer point for LSMT observers
- SightlineElement.jsx: update global TimeControl when observer time inputs
  are changed (blur/Enter), fixing Mars SOL time not updating the TimeUI
- Lunar ref mission config: add Moon (LSMT) observer with type=lsmt

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: LSMT lng fallback to map center, hide empty DEM dropdown, Enter key on observer time

- _getObserverLng: fall back to map center when indicatorLastDragPoint is null
- SightlineElement: hide DEM dropdown when no data options configured
- SightlineElement: add onKeyDown Enter handler on observer time inputs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Improve Mars Reference Mission

* chore: bump version to 5.0.28-20260610 [version bump]

* fix: sightmap overlay CRS mismatch for non-custom projections, restore config descriptions

- Only use _projImageOverlay and viewport clipping when the mission uses
  a custom projected CRS (projection.custom=true). For standard longlat/
  Mercator missions (like Mars), the DEM's projected bounds are in a
  different CRS than the map, causing misplaced overlays.
- Restore Layer-specific DEMs config row and improve field descriptions
  in sightline tool config.json (lost during ShadeTool→SightlineTool rename).
- Add sweepColorRamps and observer type examples to descriptionFull.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: restore sightline config descriptions from ShadeTool, remove data row, clear default name

- Remove Layer-specific DEMs config row (previously asked to remove)
- Restore detailed descriptions from old ShadeTool config:
  - Sources: documents name/value properties, dropdown usage, kernel path
  - Observers: documents name/value/frame/body, chronos setup path
  - Default Height: full description of height parameter behavior
  - Observer Time Placeholder: documents format string usage
  - Frame/Body fields: proper SPICE reference descriptions
- Remove 'Sightline N' default element name (now empty)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: observer time input 7hr drift — chronice result parsed as local instead of UTC

Root cause: chronice lmst→utc returns '2026-05-30T21:36:57.975' (no Z suffix).
The old ShadeTool correctly did: result.replace(' ', 'T') + 'Z'
The new code used a regex chain that failed when milliseconds were present
without a trailing Z, leaving the string timezone-ambiguous. new Date()
then parsed it as local time (UTC-7), adding ~7 hours.

Fix: strip milliseconds then …
* chore: bump version to 5.0.22-20260603 [version bump]

* perf(ShadeTool): throttle composite hover, debounce time changes, fix graph play interval leak

- Throttle _onCompositeHover with requestAnimationFrame (was triggering
  Zustand store updates + React re-renders on every mousemove)
- Debounce _onTimeChange by 300ms (was firing shade() for all elements
  on every TimeControl tick during animation)
- Fix graph play interval leak: use module-level var cleared on close()
- Add window resize listener + pxIsTools store subscription for
  responsive graph redraw (ResizeObserver alone was insufficient)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(legal): audit attributions.js and add NOTICE file

- Create root NOTICE file per Apache 2.0 conventions
- Fix misattributed leaflet-corridor entry: file is actually
  leaflet-distance-markers by Doroszlai Attila & Phil Whitehurst
- Fix incorrect license types:
  - DataTables: Custom -> MIT (verified via bundled license.txt)
  - Leaflet: Custom -> BSD-2-Clause (verified via file header)
  - shapefile: Custom -> BSD-2-Clause (upstream mbostock/shapefile)
  - Node.js: Custom -> MIT (verified upstream)
  - Marked: Custom -> MIT (verified upstream)
- Update stale version numbers:
  - D3: add v7 entry (package.json has ^7.9.0)
  - jQuery: 1.11.3 -> 3.5.1 (package.json)
  - Turf: 5.1.6 -> 6.5.0 (package.json)
  - NippleJS: 0.7.1 -> 0.8.5 (package.json)
  - Leaflet: 1.4.0 -> 1.5.1 (vendored file name)
  - CodeMirror: 5.19.0 -> 6 (configure/package.json uses @codemirror v6)
- Add missing vendored dependency entries:
  - arc.js (BSD-2-Clause, Dane Springmeyer)
  - jqColorPicker/tinyColorPicker (MIT, Peter Dematté)
  - Dropy (MIT, Tombek/CodePen)
  - Leaflet.hotline (ISC, iosphere GmbH)
  - Leaflet.pattern (BSD-2-Clause, Tyler Eastman)
  - Leaflet.RotatedMarker (MIT, Benjamin Becquet)
  - Leaflet.Snap (MIT, Makina Corpus)
  - Leaflet.GeometryUtil (BSD-3-Clause, Makina Corpus)
  - Leaflet.Path.Drag (MIT, Alexander Milevski)
  - Leaflet.TileLayer.GL (Beerware, Iván Sánchez)
  - Leaflet.VectorGrid (Beerware, Iván Sánchez)
  - Leaflet.ScaleFactor (MIT, Marc Chasse)
  - L.Rain (unknown origin/license)
  - zlib.js from pdf.js (MIT, Mozilla Foundation)
  - FileSaver.js (MIT, Eli Grey)
  - georaster-layer-for-leaflet (Apache-2.0, Daniel DuFour)
  - js-colormaps (MIT, der_herr_g)
  - svelte-range-slider-pips (MPL-2.0, Simon Goellner)
  - jQuery Autocomplete (MIT, Tomas Kirda)
  - Fabric.js (MIT, bundled in OpenSeadragon/)
  - planetcantile (BSD-3-Clause, Andrew Michael Annex)
- Remove phantom entries:
  - Highcharts JS: not in src/external/, not in package.json, no usage
  - RequireJS: only referenced in a comment in src/normalize.js, not used
- Retain CodeMirror: used in configure/ sub-app (v6 via npm)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.21-20260603 [version bump]

* fix(ShadeTool): pin horizon/crosshair to sweep center, improve atlas performance

- Horizon profile now uses sweep-time observer center (stored in
  sweepElData[id].sweepCenter) instead of current map center
- Azimuth hover line originates from sweep center position
- Crosshair pins to sweep center on pan (tracks via map 'move' event)
- Crosshair now has 2px black border + stronger shadow for visibility
- buildSweepAtlas: reduced chunk size from 16 to 4 frames, added
  progress reporting ('Building atlas: XX%'), broken assembleAtlas
  into chunked processing (yields to event loop between tiles)
- Toast 'Sweep complete' now shows AFTER atlas is built (was showing
  before, giving false sense of completion)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(ShadeTool): eliminate toDataURL() bottleneck from atlas frame rendering

Root cause: buildSweepAtlas was calling renderResultToTileData() for each
frame, which calls c.toDataURL() per tile (PNG encoding, ~10-20ms each).
With 128 frames × N tiles = thousands of PNG encodes blocking main thread.

Fix: new _renderFrameCanvases() method that only produces canvas objects
(drawImage clone) without any toDataURL() calls. Atlas assembly still calls
toDataURL() once per output tile (unavoidable for GL layer) but that's only
N tiles total instead of N×frames.

Also: crosshair now uses outline (doesn't affect element size) so white
fill is clearly visible with black border.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): accurate progress tracking and trajectory line wrapping

Progress bar now reflects the FULL sweep pipeline:
- 0-50%: shade computation (processChunk)
- 50%: 'Computing heatmap...' (cumulativeVisibility)
- 55-90%: 'Building atlas: X%' (frame rendering)
- 90-100%: 'Building atlas: assembling...' (tile toDataURL)
- Toast only fires at true completion

Previously the bar jumped to 100% after shade computation (fast phase)
then stayed there for 20s during atlas building with no visible progress.

Also: setTimeout(0) yield before cumulativeVisibility so the 'Computing
heatmap...' message actually paints before the synchronous computation.

Horizon chart: break trajectory line at ±180° azimuth boundary to prevent
long wrap-around lines spanning the chart when source crosses South.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(ShadeTool): rewrite atlas build to eliminate intermediate canvases

Previous approach: created 512+ intermediate canvas objects, performed
1024 drawImage operations, then assembled into atlas. Took ~15-20s.

New approach: renders pixel data directly into atlas canvas via putImageData
at the correct frame offset. Eliminates ALL intermediate canvas allocations
and drawImage calls. Benchmarked at ~2.7s for 128 frames x 8 tiles.

Additional optimizations:
- Reuse a single ImageData/buffer per tile (avoids per-frame allocation)
- Pre-compute color values outside the pixel loop (no object alloc per pixel)
- Use bitwise OR for integer division in hot loop
- Reduce processChunk batch size from 16 to 4 for smoother progress updates
- Fix progress text: show 'Computing shade X/128' instead of misleading '100%'

Also fixes horizon chart rendering order: yellow trajectory renders
BEHIND the brown area chart (rgba(90,62,35,0.8)).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): horizon chart polish, TimeUI indicator API, progress rAF fix

- Remove azimuth x-axis labels from horizon chart (cleaner look)
- Add small north arrow indicator at top center of horizon chart
- shadeGraphTimeLabel now shows full ISO time (matching vstSweepFrameLabel)
- Add TimeUI.addIndicator(id, groupId, color, time) / removeIndicator(id, groupId) API
  - Renders vertical colored lines on the TimeUI timeline
  - Re-renders on timeline zoom/pan/redraw
  - CSS: .mmgisTimeUIIndicatorLine styled as absolute-positioned colored lines
- ShadeTool adds red indicator on timeline at current playback time
  - Updated on every frame change via sweepShowAllFrames
  - Removed on destroy via TimeUI.removeIndicator(null, 'shadetool')
- Switch processChunk and buildSweepAtlas yields from setTimeout(fn,0) to
  requestAnimationFrame(fn) so the browser paints between iterations,
  making the progress bar update visibly instead of jumping 0->100%

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: north arrow up, y-axis padding, progress bar lifecycle

- North arrow in horizon chart now points upward (triangle + N label)
- Elevation y-axis label moved slightly for better readability
- Fixed progress bar: onComplete was called before buildSweepAtlas
  started, causing 'Done' state to flash before atlas build. Now
  onComplete is deferred until atlas finishes in playback mode.
- Added pct updates during tile loading (0-5%) and position API (5-15%)
  phases so progress bar moves throughout the entire pipeline.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): throttle sweep progress updates, adjust UI spacing

- Throttle sweepProgressPct Zustand updates to flush at most every 50ms
  so React can actually repaint between requestAnimationFrame callbacks.
  Previously the store was updating ~200x/sec but the ProgressButton
  never re-rendered because React batched all the rapid state changes.

- Elevation axis label: decrease translate x (4px) to give more padding
  between the label and the y-axis tick marks

- North arrow: increase gap between triangle and N text (triangle moved
  6px higher)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): progress bar now updates during sweep

The ProgressButton in ShadeElement reads el.loadingProgress, but
_flushSweepProgress was only writing to the top-level sweepProgressPct
store field (consumed by the unmounted SweepSection component).

Now _flushSweepProgress also updates the active element's
loadingProgress, so the actually-rendered ProgressButton re-renders
with intermediate percentages during the sweep.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): IconTextButton, monotonic progress, single-row time controls

- New design-system IconTextButton (base-ui button with icon + text label)
- Graph buttons now use IconTextButton instead of raw <button> elements
- Progress bar is monotonic: percentage never goes backwards during sweep
- Time controls condensed to single row: [start] → [step|min] → [end]
  with start/end read-only (set via TimeUI), only step editable

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(ShadeTool): match DrawTool header, UI refinements

- Header now uses shared mmgisToolHeader/mmgisToolTitle pattern (40px, matching DrawTool exactly)
- Tool width increased to 300px
- Time row: arrows removed, start/end are plain text (not inputs)
- vstBinaryLegend + vstTime background: var(--color-a-5)
- Graph time controls: play buttons styled like IconButton md (28px, transparent bg, 18px icons)
- shadeGraphTimeLabel font-size: 14px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): div-based multi-source visibility timeline + multi-arc horizon chart

- Replace canvas-based visibility timeline with div-based bars
- Show ALL shade elements with sweep results simultaneously
- Each element gets a labeled row with its own color
- Simplify time labels (no seconds/microseconds, omit year if constant)
- Title changed to 'Visibility Timeline'
- Horizon chart shows trajectory arcs from all linked shade maps
  (same center, time, step) with each element's own color
- Brighten dark element colors for visibility on dark backgrounds

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): fix horizon/visibility errors + sweep progress tracking

- Add missing 'store' variable in _drawHorizonCanvas (ReferenceError)
- Fix visibility timeline accessing elms[0].results instead of elms[0].ed.results
- Track sweeping element ID in _flushSweepProgress so progress updates
  target the correct element, not the (changing) activeElmId
- Reset all elements' loading state when starting a new shadeSweepAll
  to prevent stale progress from a cancelled sweep

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): fix scrub error, horizon fill, and stuck sweep progress

- Fix _scrubFromVisibilityX: access elms[0].ed.results (not elms[0].results)
- Horizon chart: include source trajectory elevations in auto-fit range,
  extend terrain fill to canvas bottom so arcs below horizon are covered
- Reset regenerating/loading state on cancelled sweeps so buttons don't
  stay stuck at partial progress when a new sweep replaces an in-flight one
- Clean up all regenerating elements in cancelSweep and shadeSweepElement

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(ShadeTool): per-element sweep run IDs for simultaneous sweeps

- Replace global _sweepRunId with per-element _sweepRunIds map so
  multiple shade maps can sweep simultaneously without cancelling
  each other
- _flushSweepProgress now takes elmId as first arg to target the
  correct element's loadingProgress
- _highWaterPcts is per-element for monotonic progress tracking
- shadeSweep takes explicit activeElmId parameter instead of reading
  store.activeElmId (which changes during concurrent sweeps)
- shadeSweepAll uses separate _sweepAllRunId for its serialization loop
- cancelSweep increments all per-element run IDs to cancel everything

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): combine horizon + visibility into single panel

- Single 'Charts' toggle button replaces separate Horizon Profile and
  Visibility Timeline buttons
- Both charts render in one combined bottom panel (horizon on top,
  visibility below, shared time controls at bottom)
- Horizon chart min elevation fixed to exactly 5° below the minimum
  terrain horizon point (saves vertical space)
- Trajectory elevations only affect the top bound, not the bottom

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): reconnect time controls to combined graph panel

- _scrubToFrame now redraws both horizon canvas and visibility timeline
  after updating the store (was only setting store + callback)
- updatePlaybackFrame no longer checks _activeView type — always
  redraws both charts since they're combined in one panel
- Fixes play/step/slider not updating charts after panel merge

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): source section closed by default, +New cycles sources

- Source collapsible section starts closed (was open)
- +New button cycles through non-custom source entities based on the
  last element's source (e.g. Sun → Moon → Sun → Moon...)
- Custom source is excluded from the cycle

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): visibility timeline uses actual visibilityPct, not horizon re-computation

The visibility timeline was re-computing visibility by comparing source
elevation against the interpolated horizon profile. This disagreed with
the actual shade computation (visibilityPct) because the horizon profile
is a simplified 1D ray-cast while the shade grid is a full terrain shadow
computation.

Now uses visibilityPct > 0 (the actual shade API result) to determine
visible vs occluded segments.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): add visibility timeline legend + use center-cell visibility

- Add 'Visibility Timeline' header bar with horizontal legend showing
  Visible/Occluded color swatches at the top of the visibility panel
- Store centerVisible (observer grid center cell) in sweep results
  instead of using grid-wide visibilityPct > 0 which was too coarse
- Falls back to visibilityPct for backward compatibility with older data

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): move legend to panel top, invert vis chart colors

- Legend bar at top-left of entire bottom panel (Visible/Terrain/Shaded)
  applies to both horizon chart and visibility timeline
- Visibility timeline: shaded segments use element color, visible = white
- Removed vis-specific header/legend (now redundant)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): revert vis colors, remove legend, add occluded label, fix slider alignment

- Reverted vis chart to original colors: element color = visible, gray = occluded
- Removed the panel-level graph legend
- Added 'occluded' suffix text to vis label (lighter weight/opacity)
- Fixed time slider at start/end of vis chart: use playIndex/(frameCount-1)
  so slider reaches exact bar edges at first and last frames
- Widened vis label column to accommodate 'occluded' suffix

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis label text to 'Occultations', full opacity, 12px font

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): don't regen static shade when time slider changes during playback

Skip composite/playback elements in _onTimeChange — they should only
scrub through existing sweep frames, not trigger a new shade computation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): remove horizon canvas right margin, shift elev label 15px right

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): invert vis chart colors — colored = occluded, gray = visible

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis chart uses horizon profile for terrain-aware visibility

Uses _interpolateHorizon(profile, azimuth) to compare source elevation
against the terrain horizon mask — the same data drawn in the horizon
chart. Source is visible only when elevation > terrain at that azimuth.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): gradient transitions at vis/occ boundaries in occultation chart

At each visible↔occluded transition, segments now use a CSS
linear-gradient that fades over ~20% of the segment width instead
of a hard color edge.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis chart correct on first open + fix double gradient

- Redraw visibility timeline after horizon profile fetch completes
  (was rendering with null profile on first open → all occluded)
- Gradient only on trailing edge of each segment (not both sides)
  to eliminate the double-gradient artifact at boundaries

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): only show results section after sweep completes

Added !el?.regenerating guard to the auto-open effect so the
results/run section stays hidden during the sweep and only opens
once regenerating is false (sweep fully complete).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): center-align vis timeline tick marks with their text labels

Use transform: translateX(-50%) on the tick container and
align-items: center so both the tick line and text are centered
at the same position.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): hide results section during sweep, only show when complete

- Initialize resultsOpen to false for non-static modes
- Add !el.regenerating guard to the render condition so even if
  resultsOpen is true, the content is hidden while regenerating
- Auto-open effect still fires when regenerating turns false

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): make vis timeline time ticks responsive to label width

Instead of hardcoded labelCol/margin-left values, measure the actual
.shadeVisBar element's left offset at render time. The time labels
container margin and scrub handler now both derive their position
from the real bar bounds, so they stay aligned regardless of label
text length.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): add fast-forward button to graph time controls

New fast-forward button (mdi-fast-forward) between play/pause and
step-forward. Toggles 4x playback speed (interval/4, min 50ms).
Active state highlighted with shadeGraphPlayBtnActive class.
Clicking play/pause while fast is active resets to normal speed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): layer z-ordering after sweeps + vis chart time label timezone

- Re-apply sweepCardOrder z-indices after sweepShowAllFrames,
  sweepShowComposite, and showSweepLayers so the first element
  stays on top instead of the last-swept one
- Fix _formatSmartTimeLabel using local timezone for month but UTC
  for day/hour/min — add timeZone:'UTC' to toLocaleString so
  2024-01-01T00:00:00Z shows 'Jan 1' not 'Dec 1'

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): charts survive window resize without going fully occluded

Root cause: window resize fires Leaflet moveend → _onPanEnd →
invalidateHorizonCache() nulls _horizonCache. The resize redraw
timeout then finds no profile, so the vis timeline defaults all
frames to occluded and the horizon chart is skipped.

Fix: _scheduleRedraw now calls fetchAndDrawHorizon() which re-uses
the cache when the position hasn't changed or re-fetches when it
has. Also added drawVisibilityTimeline in the cache-hit path of
fetchAndDrawHorizon so both charts always update together.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): remove link button, center playback controls, filter charts by center+time

- Removed playbackLinked toggle and all related state (localPlayIndex,
  handleToggleLinked, vstLink styles). All elements now share the
  global sweepPlayIndex.
- Centered playback controls in the sweep row.
- Charts (horizon + visibility) now only include shade maps whose
  sweep center, start time, step, and frame count match the element
  whose Charts button was clicked.
- Added info banner at top-left of bottom panel when shade maps are
  excluded (shows count and reason).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): colored azimuth lines on map while graphs open; fix stale slider on re-sweep

- Added persistent colored dashed azimuth lines on the map for each
  shade element while the charts panel is open. Each line uses the
  element's color and updates on every frame change (scrub, play,
  step). Lines are removed when graphs close or ShadeTool is destroyed.
- Fixed the time slider retaining the old frame count after a new sweep
  with fewer steps: updatePlaybackFrame now re-syncs slider.max from
  the current sweep results on every call.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): horizon profile skips near-observer DEM pixels and accounts for curvature

- HorizonProfile.py now accepts minSkipRadius (meters) and
  planetRadius (meters) parameters. Rays skip DEM pixels within
  minSkipRadius of the observer to avoid blocky near-field artifacts.
  When planetRadius > 0, applies curvature drop (d²/2R) so distant
  terrain dips below the flat-earth projection.
- Frontend sends minSkipRadius=50m and planetRadius from
  F_.radiusOfPlanetMajor when vars.curvature config is enabled
  (defaults to true).
- Backend route passes the two new params through to the Python script.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): trim seconds from vstTimeReadonly display

Display shows e.g. '2024-01-01T00:00Z' instead of '2024-01-01T00:00:00Z'.
Full timestamp preserved in title tooltip. Stored value unchanged.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): Charts button height 28px, font-size 12px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vstTimeReadonly styling, hide empty playback section on mode switch

- vstTimeReadonly: font-size 11px, opacity 0.8, first child text-align right
- Close results section on mode change so switching composite→playback
  doesn't show an empty run section
- Auto-open now checks mode-specific data (grids for playback, atlas
  for composite) instead of just results existence

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): prevent empty playback run section after composite→playback switch

Remove shadeMode from auto-open effect deps so that merely switching
modes doesn't re-open the results section with stale data from the
previous mode. The effect now only fires when actual sweep data changes
(results, grids, atlas, regenerating state), ensuring the section
stays closed after a mode switch until a new sweep completes.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): change default icon to mdi-brightness-4

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): Charts button color var(--color-a7)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): theme-aware canvases + composite auto-open results

- Az/El indicators, sky dome, horizon chart: fixed dark (#1a1e22)
  backgrounds so they remain legible in light theme
- Vis time tick lines/text: use var(--color-a4) instead of hardcoded
  white rgba
- Composite sweep auto-open: check el.lastResultGrid (set by composite
  path) instead of ed.atlas (only set by playback sweep)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(TimeUI): mmgisTimeUITimelineExtent z-index, pointer-events, opacity

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): legend hover indicator and colorstop labels always use light text

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): lighten az/el canvas backgrounds, theme-aware sky dome labels

- Static az/el: dark fill (#2a3038) + light stroke/axis lines (was white 0.1 fill + black stroke)
- Playback az/el: lightened from #1a1e22 to #2a3038
- Sky dome N/S/E/W labels: read --color-f from CSS so they're dark in light mode

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): horizon profile fully theme-responsive

- Background reads --color-a (matches page bg in both themes)
- Grid lines use black in light mode, white in dark mode
- Tick labels, axis title use --color-a4 (muted text)
- North arrow/label uses --color-f (text color)
- Terrain fill lighter/more transparent in light mode
- 0° line adapts to theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis chart visible segments near-white in light mode

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis chart tick marks now span full time range

Position ticks using i/(N-1) instead of i/N so the last tick
lands at 100%. Always include the final time label. Step size
calculated from (N-1)/(maxLabels-1) to evenly distribute ticks
across the full range.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis chart ticks evenly spaced 0%-100%, no drift

Ticks are now placed at evenly-spaced visual positions and each
maps back to the nearest frame index for its label. This avoids
the gradual drift caused by ticks and bar segments using different
coordinate systems (i/(N-1) vs i/N).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): all canvas circle backgrounds to #2c2f30

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): canvas circle backgrounds to black

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): canvas circle backgrounds to #0f1010

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(security): add path validation + numeric checks to /gethorizonprofile

- Validate required fields (path, lat, lng)
- Full URL decoding loop to catch encoded traversal
- Restrict paths to /Missions/ (cross-mission ../ allowed)
- Resolved path must stay within Missions directory
- All numeric params validated as finite numbers
- numAzimuths capped at 3600, maxRadius at 100000 (DoS prevention)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs+tests(ShadeTool): E2E tests for horizon profile API, update Shade docs

- E2E tests: input validation, path traversal protection, numeric checks
- Docs: shade modes (static/composite/playback), charts panel (horizon
  profile + occultation timeline), sky dome, azimuth lines, time controls

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(utils): extract shared validateMissionsPath helper

- New validateMissionsPath() handles URL decode loop + /Missions check +
  path.resolve guard in one place
- queryTilesetTimesDir and /gethorizonprofile both use the shared helper
- Fix E2E test: replace Infinity test (unrepresentable in JSON) with NaN string

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(tests): horizon profile E2E tests skip gracefully in AUTH=local mode

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(ShadeTool): canvas circle backgrounds to #575d60

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(ShadeTool): canvas circle backgrounds to #3a3e40

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(utils): validateMissionsPath accepts paths without leading slash

Frontend sends 'Missions/...' (no leading slash) via L_.missionPath.
Normalise by prepending '/' before the prefix check.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): pass resolved path to HorizonProfile.py + add tippy tooltips

- gethorizonprofile now passes pathResult.resolved (full filesystem path)
  instead of pathResult.decoded (/Missions/...) to the Python script
- Added tippy tooltips: 'Start Time', 'End Time' on vstTimeReadonly spans,
  and step size description on the InputWithUnit

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(ShadeTool): blue-themed Tooltip for time fields + close button shifted left 6px

- Replaced raw tippy refs with design-system <Tooltip> (blue theme)
- Close button in header shifted left 6px via margin-right: -6px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(security): append trailing slash to startsWith check in validateMissionsPath

Prevents /MissionsBackup/... from passing validation. Matches the
pattern already used in scripts/middleware.js (isPathInsideRoot).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(HorizonProfile): bounds-check observer pixel before array access

Return flat profile when observer falls outside the DEM read region
instead of crashing with IndexError.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): export improvements, color ramps, no-data fix, thicker azimuth lines

1. Export TXT: add bounding box in meters + projection info to grid export
2. Export CSV: row-per-pixel format (entity,time_range/time,lat,lng,percent_visible)
   instead of one row per frame — uses heatmap data for both composite and playback
3. Color ramps: add two element-color-based ramps (Fade: transparent→color→transparent,
   Edges: color→transparent→color) that use the shademap's user-set color
4. No-data: hide faint red coloring (val===9) — render as fully transparent instead
5. Azimuth lines: increase stroke-width from 2 to 3.5 for better visibility on map

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.23-20260604 [version bump]

* fix(ShadeTool): export TXT bounding box uses CRS project() for projected meters

Use window.mmgisglobal.customCRS.project() to convert SW/NE corners
to proper projected coordinates instead of simple degree-to-meter
conversion. Also includes proj4 string and cell size in projected units.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): color ramps use proper RGBA transparency, 3 stops each

The new ramps (Fade: transparent→color→transparent, Edges:
color→transparent→color) now use 3 RGBA color stops with the 4th
component controlling alpha. evalColor interpolates alpha when present.
Renderer and legend both read the interpolated alpha for these ramps.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): include observer height in horizon cache key

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ColorRampPicker): rewrite dropdown to use continuous gradient with RGBA alpha support

- Replace discrete block rendering with continuous linear-gradient matching
  the legend's interpolation approach
- Shadow ramp dropdown now matches legend direction (opaque→transparent)
- New alpha-based ramps (_tct, _ctc) properly show transparency over
  checkerboard background in both the trigger and popup
- All ramps with hasAlpha flag get a checkerboard underlay
- Standard colormaps (viridis, plasma, etc.) render as smooth gradients

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): split composite CSV time range fields

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): refresh color-based sweeps after color changes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(Sightline): rename ShadeTool → SightlineTool, invert rendering semantics

- Rename entire Shade tool directory to Sightline (git mv)
- Rename all files: ShadeTool.js → SightlineTool.js, etc.
- Rename store: useShadeStore → useSightlineStore
- Rename CSS classes: .shade* → .sightline*, #shadeTool → #sightlineTool
- Rename internal functions: shade() → sightline(), shadeSweep → sightlineSweep
- Rename default color ramp: 'shadow' → 'sightline'
- Update config.json: name 'Shade' → 'Sightline', paths updated
- Update blueprint reference mission config
- Update all test files and fixtures
- Update docs (Shade.md → Sightline.md, SPICE docs)
- Update imports in tools.js, AnalysisTool comment, Globe_ comments

Rendering inversion (done in prior commit, preserved here):
- store.js: invert default 1 → 0 (visible = prominent color)
- Composite heatmap: alpha = alphaFrac (not 1-alphaFrac)
- Vis chart: colored bars = visible (not occluded)
- Color ramp: alpha = t (not 1-t)
- Legend: isSightlineRamp uses alphaFrac directly

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.24-20260606 [version bump]

* fix(SightlineTool): complete rename cleanup, brighter colors, fix Devin Review findings

- Fix tool title 'Shade' → 'Sightline' in panel header
- Rename help file ShadeTool.md → SightlineTool.md, update all content
- Fix default element name 'Shade N' → 'Sightline N'
- Brighter default MULTI_SOURCE_COLORS (amber, coral, blue, green, pink, etc.)
- Fix SweepCard stale 'shadow' colorRamp fallback → 'sightline' (Devin Review)
- Fix ShaderTool_Algorithm import alias → SightlineTool_Algorithm
- Rename internal functions: computeShade → computeSightline,
  showShademapLayers → showSightlinemapLayers,
  clearAllShadeLayers → clearAllSightlineLayers,
  reorderShadeLayers → reorderSightlineLayers
- Fix SightlinePanel calling stale reorderShadeLayers
- Update export labels: 'Shade Map' → 'Sightline Map', 'Shade Grid' → 'Sightline Grid'
- Update progress/toast strings from 'Shade' to 'Sightline'
- Update SPICE docs reference
- Update help file section headers and descriptions

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): default sightline color ramp uses element color instead of black

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): remove vstBinaryLegend, default opacity 50%

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): crosshair redesign — unfilled circle with N/S/E/W tick lines

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.25-20260608 [version bump]

* fix(SightlineTool): full-size pixelated PNG export, mode-aware CSV/TXT, rename to Results

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): entity lowercase, ISO UTC times in exports, static TXT header fix, smaller crosshair

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(SightlineTool): playback export as animated GIF with basemap overlay

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): header close button margin-right 6px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF export - capture full map container, guard against 0-size canvas

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF export - hide UI controls, add timestamp/progress, lower resolution

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF export - 720px res, UI progress indicator, remove in-frame progress bar

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF progress reaches 100% only after encoding completes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): rewrite CSV exports per mode, remove TXT for playback

- Static: entity,time,lat,lng,visible (single time, binary)
- Playback: entity,time,lat,lng,visible (per-pixel per-frame, each frame's timestamp)
- Composite: entity,start_time,end_time,lat,lng,percent_visible (time range, heatmap)
- Remove TXT grid export option for playback mode

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): reset export dropdown to first item on mode change

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(blueprints): add SightlineTool to Lunar South Pole + new Mars reference mission

- Add time section and SightlineTool config to Lunar South Pole mission
- Create new Reference-Mission-Mars with LayersTool + SightlineTool (Sol observers)
- Reference Mission already has SightlineTool on development branch

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.26-20260608 [version bump]

* feat: register Mars variant in reference mission registries

Add Mars entry to REFERENCE_MISSION_VARIANTS in both:
- API/Backend/Utils/missionTemplates.js (backend)
- configure/src/.../NewMissionModal.js (frontend dropdown)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add lunar ref mission dem data

* Add lunar ref mission dem data 2

* fix: sample bbox perimeter for tile bounds in polar projections

The bounding box clamping in SightlineTool and ViewshedTool
updateDesiredTiles() projected only two corner points of the bbox to
compute tile bounds. In polar stereographic projections (e.g. Lunar
South Pole IAU2000:30120), opposite corners of a lat/lon bbox can map
to the same pixel-space point, producing a degenerate or inverted tile
range — resulting in zero tiles fetched and no sightmap/viewshed
overlay rendered.

Fix: sample 9 evenly-spaced points along each of the four bbox edges
(36 points total) and take the pixel-space envelope. This correctly
handles any map projection.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: projection-aware azimuth lines and source positioning for polar CRS

- locateSource: skip wrapping for custom (non-wrapping) projections so
  distant sources like Sun produce nearly-parallel rays instead of being
  folded into the grid as a nearby point source
- AZ hover line: rotate by local north offset derived from the projection
  so the bearing line points in the correct geographic direction
- Source azimuth lines: same north-offset correction
- Add _localNorthAngle() helper that computes screen-space north direction
  at any lat/lng by projecting a small latitude offset

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): skip observer-centric curvature for distant sources

When the ray source (e.g. Sun) is far outside the DEM grid, the
observer-centric curvature correction in curveData() creates a
circular visibility artifact — the terrain bowl centered on the
observer falsely hides all cells beyond ~350 km.

Skip the curvature correction when dataSource is more than one
grid-width outside the data bounds.  The shadow-plane propagation
already handles parallel-ray geometry correctly on the flat grid.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add backend sightmap endpoint with per-cell ray-march

- New Python endpoint /api/sightmap computes solar illumination grids
  server-side using SPICE for Sun position + DEM ray-marching
- Frontend calls backend instead of fetching tiles + JS shadow-plane algo
- USE_BACKEND_SIGHTMAP const switch to revert to old JS algorithm
- Returns both geographic and projected bounds for polar stereo CRS
- Sightmap overlay uses image-rendering: pixelated for crisp grid cells
- Auto-detects PROJ_DATA path in conda/mamba environments

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.27-20260609 [version bump]

* refactor: consolidate sightmap to backend-only, remove old JS algorithm

- Remove USE_BACKEND_SIGHTMAP flag and all old JS shadow-plane code
- Static path: single sightmap API call (no getbands/ll2aerll)
- Sweep path: batch sightmap API call with times array
- Remove tile-based rendering (makeDataLayer, renderResultToTileData,
  atlas shader, _renderFrameCanvases, SightlineTool_Manager import)
- Rewrite renderHeatmapToMap for imageOverlay (no tile layer)
- Rewrite buildSweepAtlas to pre-render frames as dataURL images
- Rewrite sweepShowFrame to swap imageOverlay URL
- Update export functions for backend grid data structure
- Backend sightmap.py: batch mode (compute_sightmap_batch),
  extracted _ray_march_grid and _compute_bounds helpers
- Express route: dynamic timeout for batch (N*30s, max 30min)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: vectorize ray-march with NumPy + coarse Sun az/el interpolation

Replace pure-Python nested loops in _ray_march_grid() with numpy array
operations.  All output cells are now processed simultaneously at each
march step via broadcasting and fancy indexing.

Optimizations:
1. Vectorized ray march (#1): ~60x speedup on 259×259 grid (47s → 0.8s)
2. Coarse Sun az/el subgrid (#4): compute SPICE-equivalent az/el on a
   10×10 subgrid and bilinearly interpolate to all output cells, reducing
   per-cell coordinate transforms from 67K to ~400.

Helper functions added:
- _vectorized_is_nodata: array-based nodata detection
- _pixels_to_geo_batch: batch pixel→geographic via GDAL CoordinateTransformation
- _sun_azel_batch: pure-numpy geodetic Sun az/el (replaces spiceypy.georec per cell)
- _bilinear_interp_2d: pure-numpy 2D bilinear interpolation (no scipy needed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: store raeRaw/raeAllResults so az/el indicator canvases redraw after mount

The SightlineResults.jsx useEffect triggers on el.raeRaw to redraw
indicator canvases. The static callback was calling updateRAEIndicators
directly (before React mounted the canvases) and only setting raeResults
but not raeRaw/raeAllResults, so the useEffect never fired.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: playback skydome/az-el blocked by stale atlas guard + pixelated CSS selector

- SightlineElement.jsx: replace ed?.atlas checks with ed?.frameImages
  (atlas was the old WebGL texture atlas; refactored code uses frameImages)
- SightlineTool.css: add img.sightmap-pixelated selector (Leaflet applies
  className directly to the <img>, not a wrapper div)
- python-environment.yml: add numba>=0.60.0 for JIT optimization

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: Numba JIT + adaptive march + early cutoff + multiprocessing

sightmap.py performance optimizations on the vectorized ray-march:

1. Numba JIT (@numba.njit): compile inner march loop to native code
   - 259x259 grid: 0.67s cold / 0.12s warm (was 0.79s numpy-only)
2. Adaptive march step: 4x step when margin > 5°, 2x when > 2°
   - Reduces iterations for clearly-illuminated cells
3. Early cutoff: max shadow distance = MAX_TERRAIN_H / tan(el)
   - Skips pointless marching when Sun is high
4. Multiprocessing for batch: Pool(ncpu) across timestamps
   - 12 frames in 0.62s (52ms/frame) vs sequential

Overall: 47.1s → 0.12s per grid = ~380x speedup (warm cache)
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: use Pool initializer for multiprocessing (Windows spawn compat)

On Windows/macOS, multiprocessing uses 'spawn' instead of 'fork',
so module-level globals aren't inherited by worker processes.
Fix: pass shared dict via Pool(initializer=_init_pool_worker, initargs=...)
so each worker sets _mp_shared in its own module namespace.

Tested: 72 timestamps in 1.28s (18ms/frame) on 100x100 grid.
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: five bugs — az offset, playback opacity, progress indicator, mode switch, hover crash

1. sightmap.py: _compute_sun_grid used dem_rows instead of dem_cols for
   column pixel clamping → coarse subgrid positions were wrong → az/el
   off by ~30-45° on non-square DEMs

2. Playback/sweep overlay now inherits el.opacity as initial value
   instead of hardcoded 1.0. Default sweep opacity changed to null;
   applySweepOpacity falls back to el.opacity when ed.opacity unset

3. ProgressButton: added indeterminate mode (sliding bar animation)
   that activates automatically when loading=true and progress<=0.
   Static sightmap generation shows indeterminate; sweep shows %

4. switchElementMode: switching back to static now re-renders the
   cached lastData/lastResultGrid or marks changed=true for auto-regen

5. _onCompositeHover: guard against missing topLeftTile (backend
   sightmap response doesn't include tile-based fields)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct sightmap ray direction for polar stereographic projections

_compute_directions had two bugs in the projected CRS branch:

1. Missing grid convergence rotation: geographic azimuth was used
   directly in pixel space without rotating by the convergence angle
   (longitude for polar stereographic). This caused ~30-45° offset
   at the observer's longitude.

2. Inverted dy sign: cos(az) was used for dy when gt[5]<0, but
   increasing pixel-y = decreasing northing, so the formula must
   negate cos(az) — matching the geographic branch convention.

Also fix _showAzimuthLine/_updateSourceAzimuthLines: the fallback
path (no sweepCenter or indicatorLastDragPoint) now computes
centerLatLng from the map container center so _localNorthAngle
always receives a valid position for the convergence rotation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: add extensive Playwright tests for sightmap API endpoint

Covers:
- Input validation (missing fields, non-finite numerics, NaN, Infinity)
- Path traversal protection on DEM path
- Single-timestamp sightmap computation (structure, grid values, az/el plausibility)
- Batch multi-timestamp computation (results array, az variation)
- Custom Az/El source (el=0 all shadow, el=90 all visible)
- Error handling (invalid SPICE target, nonexistent DEM)
- Consistency (determinism, different times produce different grids)
- Observer height offset comparison
- maxOutputDim clamping
- Projected bounds for polar stereographic CRS

All DEM-dependent tests gracefully skip when Lunar South Pole
mission is not available. AUTH=local mode returns HTML instead
of JSON — tests detect and skip.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): use observer position for azimuth indicator north offset

The azimuth indicator line was computing _localNorthAngle from the map
center (fallback when sweepCenter is unset), which drifts as the user
pans and may be at the south pole where longitude is ~0. The sightmap
shadows are computed from the actual observer position with correct
per-cell convergence, creating a mismatch.

Fix: store the observer lat/lng as sweepCenter in sweepElData when the
static sightmap completes (same as sweep mode already does). This
ensures _showAzimuthLine, _updateSourceAzimuthLines, the crosshair
placement, and the horizon profile all use the correct observer position
for the north angle calculation.

Also add indicatorLastDragPoint fallback to _updateSourceAzimuthLines
(was missing, unlike _showAzimuthLine which already had it).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightmap): correct azimuth CW/CCW convention in _sun_azel_batch

The east vector was computed as normal × north (= Up × N = -East = West),
causing atan2(dot(sun, west), dot(sun, north)) to return counter-clockwise
azimuths. This mirrored the result: geographic 240° CW reported as 120°.

Fix: use north × normal (= N × Up = East) per right-hand rule, matching
SPICE's azccw=False (clockwise from north) convention.

Verified: batch az now matches SPICE exactly (226.2493° vs 226.2493°).
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): use fixed-width fade gradient for visibility segments

The gradient on segment transitions was 30% of the segment width,
making wide segments have long fades and narrow segments short fades.
Now uses a fixed 2% of the total bar width for all transitions,
producing uniform fade-in and fade-out lengths regardless of segment size.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): wire resolution setting to maxOutputDim + gifshot fixes

Resolution dropdown (Low/Med/High/Ultra) now controls the sightmap
grid size sent to the backend:
  Static:  100 / 200 / 400 / 800 px
  Sweep:    50 / 100 / 200 / 400 px

Also:
- Add willReadFrequently to GIF export canvas contexts (suppresses
  Chrome warning about slow getImageData readback)
- Add gifshot progressCallback to update export progress during the
  GIF encoding phase (90→100%) instead of jumping at the end

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): remove dead SightlineTool_Manager and Layer specific DEMs config

SightlineTool_Manager.js was part of the old JS tile-fetching algorithm
(now replaced by the backend sightmap.py endpoint). Nothing imports it.

Remove:
- SightlineTool_Manager.js
- 'Layer specific DEMs' config section (variables.data with demtileurl,
  minZoom, maxNativeZoom, boundingBox) from config.json and all blueprint
  mission configs
- vars.data validation in initialize() — replaced with vars.dem check

The Viewshed tool's separate Manager and its layer-level demtileurl
references are unaffected.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): downsample large DEMs at read time via GDAL decimation

High-res DEMs (e.g. 100m vs 4000m) were read fully into memory even when
the output grid was small, causing:
1. Slow computation (full array I/O + march through many more pixels)
2. Windows pickle truncation on multiprocessing (huge arrays exceed
   pickle buffer limits when serialized to spawn'd worker processes)

Fix: open_dem() now accepts max_working_dim and uses GDAL's ReadAsArray
with buf_xsize/buf_ysize for server-side bilinear decimation. The
geotransform is adjusted to match the resampled pixel grid. Working dim
is set to max(max_output_dim * 4, 1000) — 4x oversampling for terrain
detail in the ray march, capped at native resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): GDAL overview bands, DEM caching, Numba warmup, reduce working_dim

Three optimizations to cut sightmap generation time on large DEMs:

1. GDAL overview bands: If the DEM has pre-computed overviews (COGs),
   read from the overview band directly instead of decimating the full
   raster in memory. Falls back to ReadAsArray(buf_xsize/ysize) for
   DEMs without overviews.

2. DEM caching: Module-level cache keyed by (path, working_dim) avoids
   re-reading the same DEM within a batch run. LRU eviction at 4 entries.

3. Numba JIT warmup: Trigger compilation at module import with a tiny
   2x2 dummy array so the ~5-10s first-compilation cost is paid during
   startup, not during the actual sightmap computation.

4. Reduce working_dim from 4x to 2x output grid (min 500px instead of
   1000px). Halves the march distance and array sizes while maintaining
   sufficient terrain detail for shadow boundary accuracy.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: add timing instrumentation to sightmap.py

Temporary timing logs to stderr at each phase:
- imports, numba_warmup, load_kernels, spice_azel, unload_kernels
- open_dem (with working_dim and actual array size)
- _precompute_grid, _compute_sun_grid, _compute_directions
- _numba_march, total

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: pipe sightmap stderr to Node console for timing visibility

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: include timing data in sightmap JSON response body

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): disk-based .npy cache for decimated DEMs

The timing data showed open_dem takes 12.8s (GDAL reading/decimating a
large DEM) while the actual Numba march takes only 0.065s.  Since each
sightmap call spawns a new Python process, the in-memory cache is lost.

Fix: after the first GDAL decimate, save the resulting numpy array and
geotransform metadata to .npy/.json files next to the source DEM.
Subsequent process invocations load the pre-decimated array via np.load
(~0.01s).  Cache is invalidated if the source DEM file is newer.

Expected improvement: first run ~14s (unchanged), second+ runs ~2s.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): require COG format for large DEMs, remove npy cache

Replace the disk-based .npy cache with a simpler approach: require the
DEM to be a Cloud Optimized GeoTIFF (COG) when decimation is needed.
COGs have internal tiled layout + overview pyramids so GDAL can read at
any target resolution by seeking to the right bytes — no full-file scan.

If the DEM is not a COG and is large enough to need decimation, throw a
clear error with the gdal_translate command to convert it.

Small DEMs that fit within max_working_dim are read directly regardless
of format (no performance issue at small sizes).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: pass through Python error messages to frontend on sightmap failure

When sightmap.py exits with code!=0, the Node handler was returning a
generic 'sightmap computation failed' message. Now it tries to parse
the structured JSON error from stdout first, so the actual error
(e.g. 'DEM is not a COG') reaches the frontend.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sanitize COG error message — no paths or tracebacks in response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove traceback from JSON error response, log to stderr only

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: show actual sightmap error message in toast

- calls.api error callback now parses JSON response body from jqXHR
- SightlineTool error handlers display server error message (e.g. COG)
- No traceback or paths in error responses (security)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove duplicate 'sightmap error' prefix from Python error messages

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): temporarily disable multiprocessing for batch mode

Run all timestamps sequentially in the main process so the DEM stays
in memory and avoids pickle serialization overhead per worker.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightmap): remove multiprocessing and Resolution UI option

- Remove all multiprocessing infrastructure (pool, workers, shared state)
- Batch timestamps run sequentially in the main process
- Remove Resolution dropdown from UI, default to ultra (800px static, 400px sweep)
- Always auto-regenerate on map move (no resolution-gated cutoff)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: temporarily disable Numba JIT for benchmarking comparison

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: re-enable Numba JIT after benchmarking (saves ~6s per frame)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: re-add timing instrumentation to sightmap response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: compute grid convergence analytically, skip GDAL TransformPoints

_compute_directions was calling _pixels_to_geo_batch on all 940K cells
(969x969 grid) to get each cell's longitude for the convergence angle.
This took ~1s (29% of total).

Now computes convergence directly from projected coordinates using
atan2(x - false_easting, sign*(y - false_northing)) — pure numpy
math, no GDAL coordinate transform. Expected: ~0.01s.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: skip kernel unloading + increase coarse subgrid step to 50

- Remove spiceypy.unload() calls — process exits immediately after
  response so OS cleanup is sufficient. Saves ~0.235s.
- Increase COARSE_AZEL_STEP from 10 to 50 — reduces coarse subgrid
  points from ~9400 to ~400 for sun az/el interpolation. Sun position
  varies slowly across the DEM so fewer sample points suffice.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap.py

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: interpolate sun azimuth in sin/cos space to prevent wrap artifacts

When coarse subgrid has azimuth values near the 360°/0° boundary
(e.g. 355° and 5°), linear interpolation produces ~180° — completely
wrong direction that flips shadows. Now interpolates sin(az) and
cos(az) separately, then recovers the angle via atan2. This handles
the circular wrap correctly.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: viewport-aware sightmap — clip DEM read to visible area at native resolution

Frontend sends current map viewport bounds (projected coords) with each
static sightmap request. Backend clips the DEM read to the viewport
intersection, reading at native resolution (capped at maxOutputDim).

When zoomed in, this means the sightmap covers just the visible area
but at much higher resolution than the full-DEM downsampled version.
When zoomed out, viewport encompasses the full DEM and behavior is
unchanged.

For a 30993x30993 DEM zoomed to 1/10th coverage, instead of reading
the full raster decimated to 1600x1600, we read only ~3000x3000 native
pixels for the visible window — higher res and faster.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: compute viewport projected bounds from container corners, not lat/lng bbox

In polar stereographic CRS, the lat/lng bounding box from
getBounds() maps to a distorted region in projected space.
Now samples all 4 container pixel corners, projects each through
the CRS, and takes the envelope for correct projected bounds.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: position sightmap overlay using projected NW/SE corners directly

In polar/rotated CRS, L.latLngBounds normalises by min/max lat/lng,
which shuffles corners — getNorthWest() and getSouthEast() return
points that don't correspond to the projected rectangle's NW and SE.
The overlay is then mispositioned and stretched.

New _projImageOverlay() helper overrides the overlay's _reset method
to compute pixel position from the projected NW (xmin, ymax) and
SE (xmax, ymin) corners via latLngToLayerPoint, bypassing the
normalisation entirely.  Applied to all three overlay creation paths:
static sightmap, heatmap, and sweep frame.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: override _animateZoom on projected overlay to prevent zoom jump

The default _animateZoom reads the normalised L.latLngBounds which
has wrong corners in polar CRS, causing the overlay to briefly jump
to the top-left during zoom transitions.  Now _animateZoom uses the
projected NW corner via _latLngToNewLayerPoint for correct animation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: wire viewport bounds through batch/sweep mode

Frontend sweep call now sends viewportBounds.  Backend
compute_sightmap_batch accepts and passes viewport_bounds
to open_dem so playback also clips to the visible DEM region
at native resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: equalize sweep/static resolution + show 'Sweeping' on progress button

- _resolutionToMaxDim now returns 800 for both modes (was 400 for sweep)
- ProgressButton label shows children text alongside percentage
- SightlineElement shows 'Sweeping'/'Generating' while loading

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: actually commit _resolutionToMaxDim change to equalize sweep/static res

Was missed from the previous commit — sweep was still getting 400 instead of 800.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: re-fetch horizon profile after pan so charts stay in sync

When the user panned, invalidateHorizonCache set _horizonCache to null
but never triggered a re-fetch.  Subsequent scrub or playback frame
changes found the cache empty and either skipped the horizon redraw or
drew the visibility timeline with no profile (marking everything not
visible).

- _onPanEnd now calls invalidateAndRefetch() which re-fetches the
  horizon profile if the graph panel is open.
- _scrubToFrame and updatePlaybackFrame fall back to fetchAndDrawHorizon
  when the cache is null instead of drawing with missing data.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile azimuth for projected CRS (polar stereo)

HorizonProfile.py was marching along pixel/raster-space directions,
treating pixel-up as north.  In polar stereographic the grid north
axis is rotated from true north by the grid convergence angle, so the
profile azimuths were offset and the terrain silhouette appeared
rotated in the chart.

Added _grid_convergence() which computes the convergence at the
observer's projected coordinates via atan2(x - FE, -(y - FN)).  Each
geographic azimuth is now rotated by the convergence before marching
in pixel space, making the profile azimuths true geographic azimuths.
No change for geographic (unprojected) CRS.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile convergence formula for polar stereo

Two bugs in the previous convergence fix:

1. _grid_convergence used atan2(x, -y) unconditionally, but for
   south-pole stereo the sign should be +1 (north is away from
   pole = positive y). Now uses north_sign = -1 for north-pole,
   +1 for south-pole, matching sightmap.py exactly.

2. The convergence was subtracted (geo_az - convergence) when it
   should be added (geo_az + convergence), matching sightmap.py's
   convention where convergence rotates from grid north to true
   north.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: use geodesic destination for azimuth lines instead of angle rotation

Replace the _localNorthAngle + screen-space rotation approach with a
direct forward geodesic method: compute a destination lat/lng 1° along
the desired azimuth, project it through Leaflet, and draw the line.

This avoids potential compound angle errors and works correctly for
any CRS because it uses Leaflet's own projection pipeline end-to-end
rather than computing a screen-space north-offset angle and rotating.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: convert reference mission DEMs to Cloud Optimized GeoTIFF (COG)

Both the Earth (USGS SF Hill) and Lunar South Pole (LRO LOLA 4000m)
DEMs are now tiled COGs with deflate compression and overviews.
This ensures consistent behavior with the sightmap COG requirement
and enables fast overview-based reads at any resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add lunar LSMT to chronice, fix TimeUI indicator cleanup and observer time sync

- chronice.py: add lunar LSMT support using SPICE et2lst with observer
  longitude; format: LDAY-NNNNNLHH:MM:SS; reverse conversion via iterative
  refinement
- utils.js: pass optional lng param through to chronice.py
- SightlineTool.js: remove TimeUI indicator on mode switch, cancel sweep,
  resweep start, and pan-end; pass lng from observer point for LSMT observers
- SightlineElement.jsx: update global TimeControl when observer time inputs
  are changed (blur/Enter), fixing Mars SOL time not updating the TimeUI
- Lunar ref mission config: add Moon (LSMT) observer with type=lsmt

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: LSMT lng fallback to map center, hide empty DEM dropdown, Enter key on observer time

- _getObserverLng: fall back to map center when indicatorLastDragPoint is null
- SightlineElement: hide DEM dropdown when no data options configured
- SightlineElement: add onKeyDown Enter handler on observer time inputs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Improve Mars Reference Mission

* chore: bump version to 5.0.28-20260610 [version bump]

* fix: sightmap overlay CRS mismatch for non-custom projections, restore config descriptions

- Only use _projImageOverlay and viewport clipping when the mission uses
  a custom projected CRS (projection.custom=true). For standard longlat/
  Mercator missions (like Mars), the DEM's projected bounds are in a
  different CRS than the map, causing misplaced overlays.
- Restore Layer-specific DEMs config row and improve field descriptions
  in sightline tool config.json (lost during ShadeTool→SightlineTool rename).
- Add sweepColorRamps and observer type examples to descriptionFull.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: restore sightline config descriptions from ShadeTool, remove data row, clear default name

- Remove Layer-specific DEMs config row (previously asked to remove)
- Restore detailed descriptions from old ShadeTool config:
  - Sources: documents name/value properties, dropdown usage, kernel path
  - O…
…odule (#998)

* fix(ShadeTool): accurate progress tracking and trajectory line wrapping

Progress bar now reflects the FULL sweep pipeline:
- 0-50%: shade computation (processChunk)
- 50%: 'Computing heatmap...' (cumulativeVisibility)
- 55-90%: 'Building atlas: X%' (frame rendering)
- 90-100%: 'Building atlas: assembling...' (tile toDataURL)
- Toast only fires at true completion

Previously the bar jumped to 100% after shade computation (fast phase)
then stayed there for 20s during atlas building with no visible progress.

Also: setTimeout(0) yield before cumulativeVisibility so the 'Computing
heatmap...' message actually paints before the synchronous computation.

Horizon chart: break trajectory line at ±180° azimuth boundary to prevent
long wrap-around lines spanning the chart when source crosses South.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(ShadeTool): rewrite atlas build to eliminate intermediate canvases

Previous approach: created 512+ intermediate canvas objects, performed
1024 drawImage operations, then assembled into atlas. Took ~15-20s.

New approach: renders pixel data directly into atlas canvas via putImageData
at the correct frame offset. Eliminates ALL intermediate canvas allocations
and drawImage calls. Benchmarked at ~2.7s for 128 frames x 8 tiles.

Additional optimizations:
- Reuse a single ImageData/buffer per tile (avoids per-frame allocation)
- Pre-compute color values outside the pixel loop (no object alloc per pixel)
- Use bitwise OR for integer division in hot loop
- Reduce processChunk batch size from 16 to 4 for smoother progress updates
- Fix progress text: show 'Computing shade X/128' instead of misleading '100%'

Also fixes horizon chart rendering order: yellow trajectory renders
BEHIND the brown area chart (rgba(90,62,35,0.8)).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): horizon chart polish, TimeUI indicator API, progress rAF fix

- Remove azimuth x-axis labels from horizon chart (cleaner look)
- Add small north arrow indicator at top center of horizon chart
- shadeGraphTimeLabel now shows full ISO time (matching vstSweepFrameLabel)
- Add TimeUI.addIndicator(id, groupId, color, time) / removeIndicator(id, groupId) API
  - Renders vertical colored lines on the TimeUI timeline
  - Re-renders on timeline zoom/pan/redraw
  - CSS: .mmgisTimeUIIndicatorLine styled as absolute-positioned colored lines
- ShadeTool adds red indicator on timeline at current playback time
  - Updated on every frame change via sweepShowAllFrames
  - Removed on destroy via TimeUI.removeIndicator(null, 'shadetool')
- Switch processChunk and buildSweepAtlas yields from setTimeout(fn,0) to
  requestAnimationFrame(fn) so the browser paints between iterations,
  making the progress bar update visibly instead of jumping 0->100%

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: north arrow up, y-axis padding, progress bar lifecycle

- North arrow in horizon chart now points upward (triangle + N label)
- Elevation y-axis label moved slightly for better readability
- Fixed progress bar: onComplete was called before buildSweepAtlas
  started, causing 'Done' state to flash before atlas build. Now
  onComplete is deferred until atlas finishes in playback mode.
- Added pct updates during tile loading (0-5%) and position API (5-15%)
  phases so progress bar moves throughout the entire pipeline.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): throttle sweep progress updates, adjust UI spacing

- Throttle sweepProgressPct Zustand updates to flush at most every 50ms
  so React can actually repaint between requestAnimationFrame callbacks.
  Previously the store was updating ~200x/sec but the ProgressButton
  never re-rendered because React batched all the rapid state changes.

- Elevation axis label: decrease translate x (4px) to give more padding
  between the label and the y-axis tick marks

- North arrow: increase gap between triangle and N text (triangle moved
  6px higher)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): progress bar now updates during sweep

The ProgressButton in ShadeElement reads el.loadingProgress, but
_flushSweepProgress was only writing to the top-level sweepProgressPct
store field (consumed by the unmounted SweepSection component).

Now _flushSweepProgress also updates the active element's
loadingProgress, so the actually-rendered ProgressButton re-renders
with intermediate percentages during the sweep.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): IconTextButton, monotonic progress, single-row time controls

- New design-system IconTextButton (base-ui button with icon + text label)
- Graph buttons now use IconTextButton instead of raw <button> elements
- Progress bar is monotonic: percentage never goes backwards during sweep
- Time controls condensed to single row: [start] → [step|min] → [end]
  with start/end read-only (set via TimeUI), only step editable

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(ShadeTool): match DrawTool header, UI refinements

- Header now uses shared mmgisToolHeader/mmgisToolTitle pattern (40px, matching DrawTool exactly)
- Tool width increased to 300px
- Time row: arrows removed, start/end are plain text (not inputs)
- vstBinaryLegend + vstTime background: var(--color-a-5)
- Graph time controls: play buttons styled like IconButton md (28px, transparent bg, 18px icons)
- shadeGraphTimeLabel font-size: 14px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): div-based multi-source visibility timeline + multi-arc horizon chart

- Replace canvas-based visibility timeline with div-based bars
- Show ALL shade elements with sweep results simultaneously
- Each element gets a labeled row with its own color
- Simplify time labels (no seconds/microseconds, omit year if constant)
- Title changed to 'Visibility Timeline'
- Horizon chart shows trajectory arcs from all linked shade maps
  (same center, time, step) with each element's own color
- Brighten dark element colors for visibility on dark backgrounds

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): fix horizon/visibility errors + sweep progress tracking

- Add missing 'store' variable in _drawHorizonCanvas (ReferenceError)
- Fix visibility timeline accessing elms[0].results instead of elms[0].ed.results
- Track sweeping element ID in _flushSweepProgress so progress updates
  target the correct element, not the (changing) activeElmId
- Reset all elements' loading state when starting a new shadeSweepAll
  to prevent stale progress from a cancelled sweep

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): fix scrub error, horizon fill, and stuck sweep progress

- Fix _scrubFromVisibilityX: access elms[0].ed.results (not elms[0].results)
- Horizon chart: include source trajectory elevations in auto-fit range,
  extend terrain fill to canvas bottom so arcs below horizon are covered
- Reset regenerating/loading state on cancelled sweeps so buttons don't
  stay stuck at partial progress when a new sweep replaces an in-flight one
- Clean up all regenerating elements in cancelSweep and shadeSweepElement

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(ShadeTool): per-element sweep run IDs for simultaneous sweeps

- Replace global _sweepRunId with per-element _sweepRunIds map so
  multiple shade maps can sweep simultaneously without cancelling
  each other
- _flushSweepProgress now takes elmId as first arg to target the
  correct element's loadingProgress
- _highWaterPcts is per-element for monotonic progress tracking
- shadeSweep takes explicit activeElmId parameter instead of reading
  store.activeElmId (which changes during concurrent sweeps)
- shadeSweepAll uses separate _sweepAllRunId for its serialization loop
- cancelSweep increments all per-element run IDs to cancel everything

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): combine horizon + visibility into single panel

- Single 'Charts' toggle button replaces separate Horizon Profile and
  Visibility Timeline buttons
- Both charts render in one combined bottom panel (horizon on top,
  visibility below, shared time controls at bottom)
- Horizon chart min elevation fixed to exactly 5° below the minimum
  terrain horizon point (saves vertical space)
- Trajectory elevations only affect the top bound, not the bottom

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): reconnect time controls to combined graph panel

- _scrubToFrame now redraws both horizon canvas and visibility timeline
  after updating the store (was only setting store + callback)
- updatePlaybackFrame no longer checks _activeView type — always
  redraws both charts since they're combined in one panel
- Fixes play/step/slider not updating charts after panel merge

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): source section closed by default, +New cycles sources

- Source collapsible section starts closed (was open)
- +New button cycles through non-custom source entities based on the
  last element's source (e.g. Sun → Moon → Sun → Moon...)
- Custom source is excluded from the cycle

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): visibility timeline uses actual visibilityPct, not horizon re-computation

The visibility timeline was re-computing visibility by comparing source
elevation against the interpolated horizon profile. This disagreed with
the actual shade computation (visibilityPct) because the horizon profile
is a simplified 1D ray-cast while the shade grid is a full terrain shadow
computation.

Now uses visibilityPct > 0 (the actual shade API result) to determine
visible vs occluded segments.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): add visibility timeline legend + use center-cell visibility

- Add 'Visibility Timeline' header bar with horizontal legend showing
  Visible/Occluded color swatches at the top of the visibility panel
- Store centerVisible (observer grid center cell) in sweep results
  instead of using grid-wide visibilityPct > 0 which was too coarse
- Falls back to visibilityPct for backward compatibility with older data

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): move legend to panel top, invert vis chart colors

- Legend bar at top-left of entire bottom panel (Visible/Terrain/Shaded)
  applies to both horizon chart and visibility timeline
- Visibility timeline: shaded segments use element color, visible = white
- Removed vis-specific header/legend (now redundant)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): revert vis colors, remove legend, add occluded label, fix slider alignment

- Reverted vis chart to original colors: element color = visible, gray = occluded
- Removed the panel-level graph legend
- Added 'occluded' suffix text to vis label (lighter weight/opacity)
- Fixed time slider at start/end of vis chart: use playIndex/(frameCount-1)
  so slider reaches exact bar edges at first and last frames
- Widened vis label column to accommodate 'occluded' suffix

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis label text to 'Occultations', full opacity, 12px font

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): don't regen static shade when time slider changes during playback

Skip composite/playback elements in _onTimeChange — they should only
scrub through existing sweep frames, not trigger a new shade computation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): remove horizon canvas right margin, shift elev label 15px right

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): invert vis chart colors — colored = occluded, gray = visible

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis chart uses horizon profile for terrain-aware visibility

Uses _interpolateHorizon(profile, azimuth) to compare source elevation
against the terrain horizon mask — the same data drawn in the horizon
chart. Source is visible only when elevation > terrain at that azimuth.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): gradient transitions at vis/occ boundaries in occultation chart

At each visible↔occluded transition, segments now use a CSS
linear-gradient that fades over ~20% of the segment width instead
of a hard color edge.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis chart correct on first open + fix double gradient

- Redraw visibility timeline after horizon profile fetch completes
  (was rendering with null profile on first open → all occluded)
- Gradient only on trailing edge of each segment (not both sides)
  to eliminate the double-gradient artifact at boundaries

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): only show results section after sweep completes

Added !el?.regenerating guard to the auto-open effect so the
results/run section stays hidden during the sweep and only opens
once regenerating is false (sweep fully complete).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): center-align vis timeline tick marks with their text labels

Use transform: translateX(-50%) on the tick container and
align-items: center so both the tick line and text are centered
at the same position.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): hide results section during sweep, only show when complete

- Initialize resultsOpen to false for non-static modes
- Add !el.regenerating guard to the render condition so even if
  resultsOpen is true, the content is hidden while regenerating
- Auto-open effect still fires when regenerating turns false

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): make vis timeline time ticks responsive to label width

Instead of hardcoded labelCol/margin-left values, measure the actual
.shadeVisBar element's left offset at render time. The time labels
container margin and scrub handler now both derive their position
from the real bar bounds, so they stay aligned regardless of label
text length.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): add fast-forward button to graph time controls

New fast-forward button (mdi-fast-forward) between play/pause and
step-forward. Toggles 4x playback speed (interval/4, min 50ms).
Active state highlighted with shadeGraphPlayBtnActive class.
Clicking play/pause while fast is active resets to normal speed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): layer z-ordering after sweeps + vis chart time label timezone

- Re-apply sweepCardOrder z-indices after sweepShowAllFrames,
  sweepShowComposite, and showSweepLayers so the first element
  stays on top instead of the last-swept one
- Fix _formatSmartTimeLabel using local timezone for month but UTC
  for day/hour/min — add timeZone:'UTC' to toLocaleString so
  2024-01-01T00:00:00Z shows 'Jan 1' not 'Dec 1'

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): charts survive window resize without going fully occluded

Root cause: window resize fires Leaflet moveend → _onPanEnd →
invalidateHorizonCache() nulls _horizonCache. The resize redraw
timeout then finds no profile, so the vis timeline defaults all
frames to occluded and the horizon chart is skipped.

Fix: _scheduleRedraw now calls fetchAndDrawHorizon() which re-uses
the cache when the position hasn't changed or re-fetches when it
has. Also added drawVisibilityTimeline in the cache-hit path of
fetchAndDrawHorizon so both charts always update together.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): remove link button, center playback controls, filter charts by center+time

- Removed playbackLinked toggle and all related state (localPlayIndex,
  handleToggleLinked, vstLink styles). All elements now share the
  global sweepPlayIndex.
- Centered playback controls in the sweep row.
- Charts (horizon + visibility) now only include shade maps whose
  sweep center, start time, step, and frame count match the element
  whose Charts button was clicked.
- Added info banner at top-left of bottom panel when shade maps are
  excluded (shows count and reason).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): colored azimuth lines on map while graphs open; fix stale slider on re-sweep

- Added persistent colored dashed azimuth lines on the map for each
  shade element while the charts panel is open. Each line uses the
  element's color and updates on every frame change (scrub, play,
  step). Lines are removed when graphs close or ShadeTool is destroyed.
- Fixed the time slider retaining the old frame count after a new sweep
  with fewer steps: updatePlaybackFrame now re-syncs slider.max from
  the current sweep results on every call.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): horizon profile skips near-observer DEM pixels and accounts for curvature

- HorizonProfile.py now accepts minSkipRadius (meters) and
  planetRadius (meters) parameters. Rays skip DEM pixels within
  minSkipRadius of the observer to avoid blocky near-field artifacts.
  When planetRadius > 0, applies curvature drop (d²/2R) so distant
  terrain dips below the flat-earth projection.
- Frontend sends minSkipRadius=50m and planetRadius from
  F_.radiusOfPlanetMajor when vars.curvature config is enabled
  (defaults to true).
- Backend route passes the two new params through to the Python script.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): trim seconds from vstTimeReadonly display

Display shows e.g. '2024-01-01T00:00Z' instead of '2024-01-01T00:00:00Z'.
Full timestamp preserved in title tooltip. Stored value unchanged.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): Charts button height 28px, font-size 12px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vstTimeReadonly styling, hide empty playback section on mode switch

- vstTimeReadonly: font-size 11px, opacity 0.8, first child text-align right
- Close results section on mode change so switching composite→playback
  doesn't show an empty run section
- Auto-open now checks mode-specific data (grids for playback, atlas
  for composite) instead of just results existence

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): prevent empty playback run section after composite→playback switch

Remove shadeMode from auto-open effect deps so that merely switching
modes doesn't re-open the results section with stale data from the
previous mode. The effect now only fires when actual sweep data changes
(results, grids, atlas, regenerating state), ensuring the section
stays closed after a mode switch until a new sweep completes.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): change default icon to mdi-brightness-4

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): Charts button color var(--color-a7)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): theme-aware canvases + composite auto-open results

- Az/El indicators, sky dome, horizon chart: fixed dark (#1a1e22)
  backgrounds so they remain legible in light theme
- Vis time tick lines/text: use var(--color-a4) instead of hardcoded
  white rgba
- Composite sweep auto-open: check el.lastResultGrid (set by composite
  path) instead of ed.atlas (only set by playback sweep)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(TimeUI): mmgisTimeUITimelineExtent z-index, pointer-events, opacity

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): legend hover indicator and colorstop labels always use light text

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): lighten az/el canvas backgrounds, theme-aware sky dome labels

- Static az/el: dark fill (#2a3038) + light stroke/axis lines (was white 0.1 fill + black stroke)
- Playback az/el: lightened from #1a1e22 to #2a3038
- Sky dome N/S/E/W labels: read --color-f from CSS so they're dark in light mode

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): horizon profile fully theme-responsive

- Background reads --color-a (matches page bg in both themes)
- Grid lines use black in light mode, white in dark mode
- Tick labels, axis title use --color-a4 (muted text)
- North arrow/label uses --color-f (text color)
- Terrain fill lighter/more transparent in light mode
- 0° line adapts to theme

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis chart visible segments near-white in light mode

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis chart tick marks now span full time range

Position ticks using i/(N-1) instead of i/N so the last tick
lands at 100%. Always include the final time label. Step size
calculated from (N-1)/(maxLabels-1) to evenly distribute ticks
across the full range.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): vis chart ticks evenly spaced 0%-100%, no drift

Ticks are now placed at evenly-spaced visual positions and each
maps back to the nearest frame index for its label. This avoids
the gradual drift caused by ticks and bar segments using different
coordinate systems (i/(N-1) vs i/N).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): all canvas circle backgrounds to #2c2f30

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): canvas circle backgrounds to black

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): canvas circle backgrounds to #0f1010

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(security): add path validation + numeric checks to /gethorizonprofile

- Validate required fields (path, lat, lng)
- Full URL decoding loop to catch encoded traversal
- Restrict paths to /Missions/ (cross-mission ../ allowed)
- Resolved path must stay within Missions directory
- All numeric params validated as finite numbers
- numAzimuths capped at 3600, maxRadius at 100000 (DoS prevention)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs+tests(ShadeTool): E2E tests for horizon profile API, update Shade docs

- E2E tests: input validation, path traversal protection, numeric checks
- Docs: shade modes (static/composite/playback), charts panel (horizon
  profile + occultation timeline), sky dome, azimuth lines, time controls

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(utils): extract shared validateMissionsPath helper

- New validateMissionsPath() handles URL decode loop + /Missions check +
  path.resolve guard in one place
- queryTilesetTimesDir and /gethorizonprofile both use the shared helper
- Fix E2E test: replace Infinity test (unrepresentable in JSON) with NaN string

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(tests): horizon profile E2E tests skip gracefully in AUTH=local mode

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(ShadeTool): canvas circle backgrounds to #575d60

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(ShadeTool): canvas circle backgrounds to #3a3e40

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(utils): validateMissionsPath accepts paths without leading slash

Frontend sends 'Missions/...' (no leading slash) via L_.missionPath.
Normalise by prepending '/' before the prefix check.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): pass resolved path to HorizonProfile.py + add tippy tooltips

- gethorizonprofile now passes pathResult.resolved (full filesystem path)
  instead of pathResult.decoded (/Missions/...) to the Python script
- Added tippy tooltips: 'Start Time', 'End Time' on vstTimeReadonly spans,
  and step size description on the InputWithUnit

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* style(ShadeTool): blue-themed Tooltip for time fields + close button shifted left 6px

- Replaced raw tippy refs with design-system <Tooltip> (blue theme)
- Close button in header shifted left 6px via margin-right: -6px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(security): append trailing slash to startsWith check in validateMissionsPath

Prevents /MissionsBackup/... from passing validation. Matches the
pattern already used in scripts/middleware.js (isPathInsideRoot).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(HorizonProfile): bounds-check observer pixel before array access

Return flat profile when observer falls outside the DEM read region
instead of crashing with IndexError.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(ShadeTool): export improvements, color ramps, no-data fix, thicker azimuth lines

1. Export TXT: add bounding box in meters + projection info to grid export
2. Export CSV: row-per-pixel format (entity,time_range/time,lat,lng,percent_visible)
   instead of one row per frame — uses heatmap data for both composite and playback
3. Color ramps: add two element-color-based ramps (Fade: transparent→color→transparent,
   Edges: color→transparent→color) that use the shademap's user-set color
4. No-data: hide faint red coloring (val===9) — render as fully transparent instead
5. Azimuth lines: increase stroke-width from 2 to 3.5 for better visibility on map

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.23-20260604 [version bump]

* fix(ShadeTool): export TXT bounding box uses CRS project() for projected meters

Use window.mmgisglobal.customCRS.project() to convert SW/NE corners
to proper projected coordinates instead of simple degree-to-meter
conversion. Also includes proj4 string and cell size in projected units.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): color ramps use proper RGBA transparency, 3 stops each

The new ramps (Fade: transparent→color→transparent, Edges:
color→transparent→color) now use 3 RGBA color stops with the 4th
component controlling alpha. evalColor interpolates alpha when present.
Renderer and legend both read the interpolated alpha for these ramps.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): include observer height in horizon cache key

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ColorRampPicker): rewrite dropdown to use continuous gradient with RGBA alpha support

- Replace discrete block rendering with continuous linear-gradient matching
  the legend's interpolation approach
- Shadow ramp dropdown now matches legend direction (opaque→transparent)
- New alpha-based ramps (_tct, _ctc) properly show transparency over
  checkerboard background in both the trigger and popup
- All ramps with hasAlpha flag get a checkerboard underlay
- Standard colormaps (viridis, plasma, etc.) render as smooth gradients

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): split composite CSV time range fields

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(ShadeTool): refresh color-based sweeps after color changes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(Sightline): rename ShadeTool → SightlineTool, invert rendering semantics

- Rename entire Shade tool directory to Sightline (git mv)
- Rename all files: ShadeTool.js → SightlineTool.js, etc.
- Rename store: useShadeStore → useSightlineStore
- Rename CSS classes: .shade* → .sightline*, #shadeTool → #sightlineTool
- Rename internal functions: shade() → sightline(), shadeSweep → sightlineSweep
- Rename default color ramp: 'shadow' → 'sightline'
- Update config.json: name 'Shade' → 'Sightline', paths updated
- Update blueprint reference mission config
- Update all test files and fixtures
- Update docs (Shade.md → Sightline.md, SPICE docs)
- Update imports in tools.js, AnalysisTool comment, Globe_ comments

Rendering inversion (done in prior commit, preserved here):
- store.js: invert default 1 → 0 (visible = prominent color)
- Composite heatmap: alpha = alphaFrac (not 1-alphaFrac)
- Vis chart: colored bars = visible (not occluded)
- Color ramp: alpha = t (not 1-t)
- Legend: isSightlineRamp uses alphaFrac directly

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.24-20260606 [version bump]

* fix(SightlineTool): complete rename cleanup, brighter colors, fix Devin Review findings

- Fix tool title 'Shade' → 'Sightline' in panel header
- Rename help file ShadeTool.md → SightlineTool.md, update all content
- Fix default element name 'Shade N' → 'Sightline N'
- Brighter default MULTI_SOURCE_COLORS (amber, coral, blue, green, pink, etc.)
- Fix SweepCard stale 'shadow' colorRamp fallback → 'sightline' (Devin Review)
- Fix ShaderTool_Algorithm import alias → SightlineTool_Algorithm
- Rename internal functions: computeShade → computeSightline,
  showShademapLayers → showSightlinemapLayers,
  clearAllShadeLayers → clearAllSightlineLayers,
  reorderShadeLayers → reorderSightlineLayers
- Fix SightlinePanel calling stale reorderShadeLayers
- Update export labels: 'Shade Map' → 'Sightline Map', 'Shade Grid' → 'Sightline Grid'
- Update progress/toast strings from 'Shade' to 'Sightline'
- Update SPICE docs reference
- Update help file section headers and descriptions

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): default sightline color ramp uses element color instead of black

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): remove vstBinaryLegend, default opacity 50%

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): crosshair redesign — unfilled circle with N/S/E/W tick lines

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.25-20260608 [version bump]

* fix(SightlineTool): full-size pixelated PNG export, mode-aware CSV/TXT, rename to Results

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): entity lowercase, ISO UTC times in exports, static TXT header fix, smaller crosshair

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(SightlineTool): playback export as animated GIF with basemap overlay

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): header close button margin-right 6px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF export - capture full map container, guard against 0-size canvas

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF export - hide UI controls, add timestamp/progress, lower resolution

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF export - 720px res, UI progress indicator, remove in-frame progress bar

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF progress reaches 100% only after encoding completes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): rewrite CSV exports per mode, remove TXT for playback

- Static: entity,time,lat,lng,visible (single time, binary)
- Playback: entity,time,lat,lng,visible (per-pixel per-frame, each frame's timestamp)
- Composite: entity,start_time,end_time,lat,lng,percent_visible (time range, heatmap)
- Remove TXT grid export option for playback mode

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): reset export dropdown to first item on mode change

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(blueprints): add SightlineTool to Lunar South Pole + new Mars reference mission

- Add time section and SightlineTool config to Lunar South Pole mission
- Create new Reference-Mission-Mars with LayersTool + SightlineTool (Sol observers)
- Reference Mission already has SightlineTool on development branch

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.26-20260608 [version bump]

* feat: register Mars variant in reference mission registries

Add Mars entry to REFERENCE_MISSION_VARIANTS in both:
- API/Backend/Utils/missionTemplates.js (backend)
- configure/src/.../NewMissionModal.js (frontend dropdown)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add lunar ref mission dem data

* Add lunar ref mission dem data 2

* fix: sample bbox perimeter for tile bounds in polar projections

The bounding box clamping in SightlineTool and ViewshedTool
updateDesiredTiles() projected only two corner points of the bbox to
compute tile bounds. In polar stereographic projections (e.g. Lunar
South Pole IAU2000:30120), opposite corners of a lat/lon bbox can map
to the same pixel-space point, producing a degenerate or inverted tile
range — resulting in zero tiles fetched and no sightmap/viewshed
overlay rendered.

Fix: sample 9 evenly-spaced points along each of the four bbox edges
(36 points total) and take the pixel-space envelope. This correctly
handles any map projection.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: projection-aware azimuth lines and source positioning for polar CRS

- locateSource: skip wrapping for custom (non-wrapping) projections so
  distant sources like Sun produce nearly-parallel rays instead of being
  folded into the grid as a nearby point source
- AZ hover line: rotate by local north offset derived from the projection
  so the bearing line points in the correct geographic direction
- Source azimuth lines: same north-offset correction
- Add _localNorthAngle() helper that computes screen-space north direction
  at any lat/lng by projecting a small latitude offset

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): skip observer-centric curvature for distant sources

When the ray source (e.g. Sun) is far outside the DEM grid, the
observer-centric curvature correction in curveData() creates a
circular visibility artifact — the terrain bowl centered on the
observer falsely hides all cells beyond ~350 km.

Skip the curvature correction when dataSource is more than one
grid-width outside the data bounds.  The shadow-plane propagation
already handles parallel-ray geometry correctly on the flat grid.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add backend sightmap endpoint with per-cell ray-march

- New Python endpoint /api/sightmap computes solar illumination grids
  server-side using SPICE for Sun position + DEM ray-marching
- Frontend calls backend instead of fetching tiles + JS shadow-plane algo
- USE_BACKEND_SIGHTMAP const switch to revert to old JS algorithm
- Returns both geographic and projected bounds for polar stereo CRS
- Sightmap overlay uses image-rendering: pixelated for crisp grid cells
- Auto-detects PROJ_DATA path in conda/mamba environments

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.27-20260609 [version bump]

* refactor: consolidate sightmap to backend-only, remove old JS algorithm

- Remove USE_BACKEND_SIGHTMAP flag and all old JS shadow-plane code
- Static path: single sightmap API call (no getbands/ll2aerll)
- Sweep path: batch sightmap API call with times array
- Remove tile-based rendering (makeDataLayer, renderResultToTileData,
  atlas shader, _renderFrameCanvases, SightlineTool_Manager import)
- Rewrite renderHeatmapToMap for imageOverlay (no tile layer)
- Rewrite buildSweepAtlas to pre-render frames as dataURL images
- Rewrite sweepShowFrame to swap imageOverlay URL
- Update export functions for backend grid data structure
- Backend sightmap.py: batch mode (compute_sightmap_batch),
  extracted _ray_march_grid and _compute_bounds helpers
- Express route: dynamic timeout for batch (N*30s, max 30min)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: vectorize ray-march with NumPy + coarse Sun az/el interpolation

Replace pure-Python nested loops in _ray_march_grid() with numpy array
operations.  All output cells are now processed simultaneously at each
march step via broadcasting and fancy indexing.

Optimizations:
1. Vectorized ray march (#1): ~60x speedup on 259×259 grid (47s → 0.8s)
2. Coarse Sun az/el subgrid (#4): compute SPICE-equivalent az/el on a
   10×10 subgrid and bilinearly interpolate to all output cells, reducing
   per-cell coordinate transforms from 67K to ~400.

Helper functions added:
- _vectorized_is_nodata: array-based nodata detection
- _pixels_to_geo_batch: batch pixel→geographic via GDAL CoordinateTransformation
- _sun_azel_batch: pure-numpy geodetic Sun az/el (replaces spiceypy.georec per cell)
- _bilinear_interp_2d: pure-numpy 2D bilinear interpolation (no scipy needed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: store raeRaw/raeAllResults so az/el indicator canvases redraw after mount

The SightlineResults.jsx useEffect triggers on el.raeRaw to redraw
indicator canvases. The static callback was calling updateRAEIndicators
directly (before React mounted the canvases) and only setting raeResults
but not raeRaw/raeAllResults, so the useEffect never fired.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: playback skydome/az-el blocked by stale atlas guard + pixelated CSS selector

- SightlineElement.jsx: replace ed?.atlas checks with ed?.frameImages
  (atlas was the old WebGL texture atlas; refactored code uses frameImages)
- SightlineTool.css: add img.sightmap-pixelated selector (Leaflet applies
  className directly to the <img>, not a wrapper div)
- python-environment.yml: add numba>=0.60.0 for JIT optimization

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: Numba JIT + adaptive march + early cutoff + multiprocessing

sightmap.py performance optimizations on the vectorized ray-march:

1. Numba JIT (@numba.njit): compile inner march loop to native code
   - 259x259 grid: 0.67s cold / 0.12s warm (was 0.79s numpy-only)
2. Adaptive march step: 4x step when margin > 5°, 2x when > 2°
   - Reduces iterations for clearly-illuminated cells
3. Early cutoff: max shadow distance = MAX_TERRAIN_H / tan(el)
   - Skips pointless marching when Sun is high
4. Multiprocessing for batch: Pool(ncpu) across timestamps
   - 12 frames in 0.62s (52ms/frame) vs sequential

Overall: 47.1s → 0.12s per grid = ~380x speedup (warm cache)
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: use Pool initializer for multiprocessing (Windows spawn compat)

On Windows/macOS, multiprocessing uses 'spawn' instead of 'fork',
so module-level globals aren't inherited by worker processes.
Fix: pass shared dict via Pool(initializer=_init_pool_worker, initargs=...)
so each worker sets _mp_shared in its own module namespace.

Tested: 72 timestamps in 1.28s (18ms/frame) on 100x100 grid.
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: five bugs — az offset, playback opacity, progress indicator, mode switch, hover crash

1. sightmap.py: _compute_sun_grid used dem_rows instead of dem_cols for
   column pixel clamping → coarse subgrid positions were wrong → az/el
   off by ~30-45° on non-square DEMs

2. Playback/sweep overlay now inherits el.opacity as initial value
   instead of hardcoded 1.0. Default sweep opacity changed to null;
   applySweepOpacity falls back to el.opacity when ed.opacity unset

3. ProgressButton: added indeterminate mode (sliding bar animation)
   that activates automatically when loading=true and progress<=0.
   Static sightmap generation shows indeterminate; sweep shows %

4. switchElementMode: switching back to static now re-renders the
   cached lastData/lastResultGrid or marks changed=true for auto-regen

5. _onCompositeHover: guard against missing topLeftTile (backend
   sightmap response doesn't include tile-based fields)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct sightmap ray direction for polar stereographic projections

_compute_directions had two bugs in the projected CRS branch:

1. Missing grid convergence rotation: geographic azimuth was used
   directly in pixel space without rotating by the convergence angle
   (longitude for polar stereographic). This caused ~30-45° offset
   at the observer's longitude.

2. Inverted dy sign: cos(az) was used for dy when gt[5]<0, but
   increasing pixel-y = decreasing northing, so the formula must
   negate cos(az) — matching the geographic branch convention.

Also fix _showAzimuthLine/_updateSourceAzimuthLines: the fallback
path (no sweepCenter or indicatorLastDragPoint) now computes
centerLatLng from the map container center so _localNorthAngle
always receives a valid position for the convergence rotation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: add extensive Playwright tests for sightmap API endpoint

Covers:
- Input validation (missing fields, non-finite numerics, NaN, Infinity)
- Path traversal protection on DEM path
- Single-timestamp sightmap computation (structure, grid values, az/el plausibility)
- Batch multi-timestamp computation (results array, az variation)
- Custom Az/El source (el=0 all shadow, el=90 all visible)
- Error handling (invalid SPICE target, nonexistent DEM)
- Consistency (determinism, different times produce different grids)
- Observer height offset comparison
- maxOutputDim clamping
- Projected bounds for polar stereographic CRS

All DEM-dependent tests gracefully skip when Lunar South Pole
mission is not available. AUTH=local mode returns HTML instead
of JSON — tests detect and skip.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): use observer position for azimuth indicator north offset

The azimuth indicator line was computing _localNorthAngle from the map
center (fallback when sweepCenter is unset), which drifts as the user
pans and may be at the south pole where longitude is ~0. The sightmap
shadows are computed from the actual observer position with correct
per-cell convergence, creating a mismatch.

Fix: store the observer lat/lng as sweepCenter in sweepElData when the
static sightmap completes (same as sweep mode already does). This
ensures _showAzimuthLine, _updateSourceAzimuthLines, the crosshair
placement, and the horizon profile all use the correct observer position
for the north angle calculation.

Also add indicatorLastDragPoint fallback to _updateSourceAzimuthLines
(was missing, unlike _showAzimuthLine which already had it).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightmap): correct azimuth CW/CCW convention in _sun_azel_batch

The east vector was computed as normal × north (= Up × N = -East = West),
causing atan2(dot(sun, west), dot(sun, north)) to return counter-clockwise
azimuths. This mirrored the result: geographic 240° CW reported as 120°.

Fix: use north × normal (= N × Up = East) per right-hand rule, matching
SPICE's azccw=False (clockwise from north) convention.

Verified: batch az now matches SPICE exactly (226.2493° vs 226.2493°).
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): use fixed-width fade gradient for visibility segments

The gradient on segment transitions was 30% of the segment width,
making wide segments have long fades and narrow segments short fades.
Now uses a fixed 2% of the total bar width for all transitions,
producing uniform fade-in and fade-out lengths regardless of segment size.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): wire resolution setting to maxOutputDim + gifshot fixes

Resolution dropdown (Low/Med/High/Ultra) now controls the sightmap
grid size sent to the backend:
  Static:  100 / 200 / 400 / 800 px
  Sweep:    50 / 100 / 200 / 400 px

Also:
- Add willReadFrequently to GIF export canvas contexts (suppresses
  Chrome warning about slow getImageData readback)
- Add gifshot progressCallback to update export progress during the
  GIF encoding phase (90→100%) instead of jumping at the end

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): remove dead SightlineTool_Manager and Layer specific DEMs config

SightlineTool_Manager.js was part of the old JS tile-fetching algorithm
(now replaced by the backend sightmap.py endpoint). Nothing imports it.

Remove:
- SightlineTool_Manager.js
- 'Layer specific DEMs' config section (variables.data with demtileurl,
  minZoom, maxNativeZoom, boundingBox) from config.json and all blueprint
  mission configs
- vars.data validation in initialize() — replaced with vars.dem check

The Viewshed tool's separate Manager and its layer-level demtileurl
references are unaffected.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): downsample large DEMs at read time via GDAL decimation

High-res DEMs (e.g. 100m vs 4000m) were read fully into memory even when
the output grid was small, causing:
1. Slow computation (full array I/O + march through many more pixels)
2. Windows pickle truncation on multiprocessing (huge arrays exceed
   pickle buffer limits when serialized to spawn'd worker processes)

Fix: open_dem() now accepts max_working_dim and uses GDAL's ReadAsArray
with buf_xsize/buf_ysize for server-side bilinear decimation. The
geotransform is adjusted to match the resampled pixel grid. Working dim
is set to max(max_output_dim * 4, 1000) — 4x oversampling for terrain
detail in the ray march, capped at native resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): GDAL overview bands, DEM caching, Numba warmup, reduce working_dim

Three optimizations to cut sightmap generation time on large DEMs:

1. GDAL overview bands: If the DEM has pre-computed overviews (COGs),
   read from the overview band directly instead of decimating the full
   raster in memory. Falls back to ReadAsArray(buf_xsize/ysize) for
   DEMs without overviews.

2. DEM caching: Module-level cache keyed by (path, working_dim) avoids
   re-reading the same DEM within a batch run. LRU eviction at 4 entries.

3. Numba JIT warmup: Trigger compilation at module import with a tiny
   2x2 dummy array so the ~5-10s first-compilation cost is paid during
   startup, not during the actual sightmap computation.

4. Reduce working_dim from 4x to 2x output grid (min 500px instead of
   1000px). Halves the march distance and array sizes while maintaining
   sufficient terrain detail for shadow boundary accuracy.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: add timing instrumentation to sightmap.py

Temporary timing logs to stderr at each phase:
- imports, numba_warmup, load_kernels, spice_azel, unload_kernels
- open_dem (with working_dim and actual array size)
- _precompute_grid, _compute_sun_grid, _compute_directions
- _numba_march, total

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: pipe sightmap stderr to Node console for timing visibility

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: include timing data in sightmap JSON response body

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): disk-based .npy cache for decimated DEMs

The timing data showed open_dem takes 12.8s (GDAL reading/decimating a
large DEM) while the actual Numba march takes only 0.065s.  Since each
sightmap call spawns a new Python process, the in-memory cache is lost.

Fix: after the first GDAL decimate, save the resulting numpy array and
geotransform metadata to .npy/.json files next to the source DEM.
Subsequent process invocations load the pre-decimated array via np.load
(~0.01s).  Cache is invalidated if the source DEM file is newer.

Expected improvement: first run ~14s (unchanged), second+ runs ~2s.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): require COG format for large DEMs, remove npy cache

Replace the disk-based .npy cache with a simpler approach: require the
DEM to be a Cloud Optimized GeoTIFF (COG) when decimation is needed.
COGs have internal tiled layout + overview pyramids so GDAL can read at
any target resolution by seeking to the right bytes — no full-file scan.

If the DEM is not a COG and is large enough to need decimation, throw a
clear error with the gdal_translate command to convert it.

Small DEMs that fit within max_working_dim are read directly regardless
of format (no performance issue at small sizes).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: pass through Python error messages to frontend on sightmap failure

When sightmap.py exits with code!=0, the Node handler was returning a
generic 'sightmap computation failed' message. Now it tries to parse
the structured JSON error from stdout first, so the actual error
(e.g. 'DEM is not a COG') reaches the frontend.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sanitize COG error message — no paths or tracebacks in response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove traceback from JSON error response, log to stderr only

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: show actual sightmap error message in toast

- calls.api error callback now parses JSON response body from jqXHR
- SightlineTool error handlers display server error message (e.g. COG)
- No traceback or paths in error responses (security)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove duplicate 'sightmap error' prefix from Python error messages

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): temporarily disable multiprocessing for batch mode

Run all timestamps sequentially in the main process so the DEM stays
in memory and avoids pickle serialization overhead per worker.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightmap): remove multiprocessing and Resolution UI option

- Remove all multiprocessing infrastructure (pool, workers, shared state)
- Batch timestamps run sequentially in the main process
- Remove Resolution dropdown from UI, default to ultra (800px static, 400px sweep)
- Always auto-regenerate on map move (no resolution-gated cutoff)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: temporarily disable Numba JIT for benchmarking comparison

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: re-enable Numba JIT after benchmarking (saves ~6s per frame)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: re-add timing instrumentation to sightmap response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: compute grid convergence analytically, skip GDAL TransformPoints

_compute_directions was calling _pixels_to_geo_batch on all 940K cells
(969x969 grid) to get each cell's longitude for the convergence angle.
This took ~1s (29% of total).

Now computes convergence directly from projected coordinates using
atan2(x - false_easting, sign*(y - false_northing)) — pure numpy
math, no GDAL coordinate transform. Expected: ~0.01s.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: skip kernel unloading + increase coarse subgrid step to 50

- Remove spiceypy.unload() calls — process exits immediately after
  response so OS cleanup is sufficient. Saves ~0.235s.
- Increase COARSE_AZEL_STEP from 10 to 50 — reduces coarse subgrid
  points from ~9400 to ~400 for sun az/el interpolation. Sun position
  varies slowly across the DEM so fewer sample points suffice.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap.py

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: interpolate sun azimuth in sin/cos space to prevent wrap artifacts

When coarse subgrid has azimuth values near the 360°/0° boundary
(e.g. 355° and 5°), linear interpolation produces ~180° — completely
wrong direction that flips shadows. Now interpolates sin(az) and
cos(az) separately, then recovers the angle via atan2. This handles
the circular wrap correctly.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: viewport-aware sightmap — clip DEM read to visible area at native resolution

Frontend sends current map viewport bounds (projected coords) with each
static sightmap request. Backend clips the DEM read to the viewport
intersection, reading at native resolution (capped at maxOutputDim).

When zoomed in, this means the sightmap covers just the visible area
but at much higher resolution than the full-DEM downsampled version.
When zoomed out, viewport encompasses the full DEM and behavior is
unchanged.

For a 30993x30993 DEM zoomed to 1/10th coverage, instead of reading
the full raster decimated to 1600x1600, we read only ~3000x3000 native
pixels for the visible window — higher res and faster.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: compute viewport projected bounds from container corners, not lat/lng bbox

In polar stereographic CRS, the lat/lng bounding box from
getBounds() maps to a distorted region in projected space.
Now samples all 4 container pixel corners, projects each through
the CRS, and takes the envelope for correct projected bounds.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: position sightmap overlay using projected NW/SE corners directly

In polar/rotated CRS, L.latLngBounds normalises by min/max lat/lng,
which shuffles corners — getNorthWest() and getSouthEast() return
points that don't correspond to the projected rectangle's NW and SE.
The overlay is then mispositioned and stretched.

New _projImageOverlay() helper overrides the overlay's _reset method
to compute pixel position from the projected NW (xmin, ymax) and
SE (xmax, ymin) corners via latLngToLayerPoint, bypassing the
normalisation entirely.  Applied to all three overlay creation paths:
static sightmap, heatmap, and sweep frame.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: override _animateZoom on projected overlay to prevent zoom jump

The default _animateZoom reads the normalised L.latLngBounds which
has wrong corners in polar CRS, causing the overlay to briefly jump
to the top-left during zoom transitions.  Now _animateZoom uses the
projected NW corner via _latLngToNewLayerPoint for correct animation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: wire viewport bounds through batch/sweep mode

Frontend sweep call now sends viewportBounds.  Backend
compute_sightmap_batch accepts and passes viewport_bounds
to open_dem so playback also clips to the visible DEM region
at native resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: equalize sweep/static resolution + show 'Sweeping' on progress button

- _resolutionToMaxDim now returns 800 for both modes (was 400 for sweep)
- ProgressButton label shows children text alongside percentage
- SightlineElement shows 'Sweeping'/'Generating' while loading

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: actually commit _resolutionToMaxDim change to equalize sweep/static res

Was missed from the previous commit — sweep was still getting 400 instead of 800.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: re-fetch horizon profile after pan so charts stay in sync

When the user panned, invalidateHorizonCache set _horizonCache to null
but never triggered a re-fetch.  Subsequent scrub or playback frame
changes found the cache empty and either skipped the horizon redraw or
drew the visibility timeline with no profile (marking everything not
visible).

- _onPanEnd now calls invalidateAndRefetch() which re-fetches the
  horizon profile if the graph panel is open.
- _scrubToFrame and updatePlaybackFrame fall back to fetchAndDrawHorizon
  when the cache is null instead of drawing with missing data.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile azimuth for projected CRS (polar stereo)

HorizonProfile.py was marching along pixel/raster-space directions,
treating pixel-up as north.  In polar stereographic the grid north
axis is rotated from true north by the grid convergence angle, so the
profile azimuths were offset and the terrain silhouette appeared
rotated in the chart.

Added _grid_convergence() which computes the convergence at the
observer's projected coordinates via atan2(x - FE, -(y - FN)).  Each
geographic azimuth is now rotated by the convergence before marching
in pixel space, making the profile azimuths true geographic azimuths.
No change for geographic (unprojected) CRS.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile convergence formula for polar stereo

Two bugs in the previous convergence fix:

1. _grid_convergence used atan2(x, -y) unconditionally, but for
   south-pole stereo the sign should be +1 (north is away from
   pole = positive y). Now uses north_sign = -1 for north-pole,
   +1 for south-pole, matching sightmap.py exactly.

2. The convergence was subtracted (geo_az - convergence) when it
   should be added (geo_az + convergence), matching sightmap.py's
   convention where convergence rotates from grid north to true
   north.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: extract rate limiters into shared scripts/rateLimiters.js module

- Create scripts/rateLimiters.js exporting apilimiter, authLimiter, computeLimiter
- Remove inline rateLimit() definitions from scripts/server.js
- Remove authLimiter/computeLimiter from the 's' setup object
- Update Users/routes/users.js: import authLimiter directly, replace late-binding wrapper
- Update Users/setup.js: remove router._authLimiter assignment
- Update Utils/routes/utils.js: import computeLimiter directly, replace all 8 late-binding wrappers
- Update Utils/setup.js: remove router._computeLimiter assignment
- Update unit tests to import from the shared module

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.26-20260609 [version bump]

* fix: use geodesic destination for azimuth lines instead of angle rotation

Replace the _localNorthAngle + screen-space rotation approach with a
direct forward geodesic method: compute a destination lat/lng 1° along
the desired azimuth, project it through Leaflet, and draw the line.

This avoids potential compound angle errors and works correctly for
any CRS because it uses Leaflet's own projection pipeline end-to-end
rather than computing a screen-space north-offset angle and rotating.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: convert reference mission DEMs to Cloud Optimized GeoTIFF (COG)

Both the Earth (USGS SF Hill) and Lunar South Pole (LRO LOLA 4000m)
DEMs are now tiled COGs with deflate compression and overviews.
This ensures consistent behavior with the sightmap COG requirement
and enables fast overview-based reads at any resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add lunar LSMT to chronice, fix TimeUI indicator cleanup and observer time sync

- chronice.py: add lunar LSMT support using SPICE et2lst with observer
  longitude; format: LDAY-NNNNNLHH:MM:SS; reverse conversion via iterative
  refinement
- utils.js: pass optional lng param through to chronice.py
- SightlineTool.js: remove TimeUI indicator on mode switch, cancel sweep,
  resweep start, and pan-end; pass lng from observer point for LSMT observers
- SightlineElement.jsx: update global TimeControl when observer time inputs
  are changed (blur/Enter), fixing Mars SOL time not updating the TimeUI
- Lunar ref mission config: add Moon (LSMT) observer with type=lsmt

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: LSMT lng fallback to map center, hide empty DEM dropdown, Enter key on observer time

- _getObserverLng: fall back to map center when indicatorLastDragPoint is null
- SightlineElement: hide DEM dropdown when no data options configured
- SightlineElement: add onKeyDown Enter handler on observer time inputs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Improve Mars Reference Mission

* chore: bump version to 5.0.28-20260610 [version bump]

* fix: sightmap overlay CRS mismatch for non-custom projections, restore config descriptions

- Only use _projImageOverlay and viewport clipping when the mission uses
  a custom projected CRS (projection.custom=true). For standard longlat/
  Mercator missions (like Mars), the DEM's projected bounds are in a
  different CRS than the map, causing misplaced overlays.
- Restore Layer-specific DEMs config row and improve field descriptions
  in sightline tool config.json (lost during ShadeTool→SightlineTool rename).
- Add sweepColorRamps and observer type examples to descriptionFull.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: restore sightline config descriptions from ShadeTool, remove data row, clear default name

- Remove Layer-specific DEMs config row (previously asked to remove)
- Restore detailed descriptions from old ShadeTool config:
  - Sources: documents name/value properties, dropdown usage, kernel path
  - Observers: documents name/value/frame/body, chronos setup path
  - Default Height: full description of height parameter behavior
  - Observer Time Placeholder: documents format string usage
  - Frame/Body fields: proper SPICE reference descriptions
- Remove 'Sightline N' default element name (now empty)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: observer time input 7hr drift — chronice result parsed as local instead of UTC

Root cause: chronice lmst→utc returns '2026-05-30T21:36:57.975' (no Z suffix).
The old ShadeTool correctly did: result.replace(' ', 'T') + 'Z'
The new code used a regex chain that failed when milliseconds were present
without a trailing Z, leaving the string timezone-ambiguous. new Date()
then parsed it as local time (UTC-7), adding ~7 hours.

Fix: strip milliseconds then unconditionally append Z, matching the old
ShadeTool approach. The /ZZ$/ → Z guard prevents double-Z if chronice
ever returns a Z-suffixed result in the future.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sightmap sun direction for cylindrical projections, 1s time drift, tab-switch regen, add editable time field

1. Sightmap sun direction: _compute_directions now only applies convergence
   rotation for azimuthal projections (stereo/gnomonic). For cylindrical
   projections (Equidistant Cylindrical, Mercator), grid north = geographic
   north so convergence = 0. Previously applied polar-stereo formula to all
   projected CRS, giving ~90deg rotation on Mars DEM.

2. Observer time 1-second drift: restored _lastConvertedMs pattern from old
   ShadeTool. Saves sub-second precision from observer->UTC conversion and
   re-attaches it in UTC->observer reverse conversion for exact round-trips.

3. Tab-switch regeneration: _onTimeChange now tracks _lastGeneratedTime and
   skips if unchanged, preventing redundant sightmap computation when
   TimeControl re-broadcasts the same time on tab refocus.

4. Editable time field (vstOptionTime): restored from old ShadeTool. Shows
   current end time in configured format (DOY, etc), editable on blur/Enter.
   Parses via utcTimeFormat if configured, else appends Z directly.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: match old ShadeTool vstOptionTime styling and DOY format

- CSS matches old ShadeTool exactly: full-width centered input, bold 14px,
  color-p0 bg, color-a1-5 text, transparent border that shows color-c on focus
- Clock icon positioned absolute right (pointer-events: none) as in original
- Structure uses flexbetween wrapper matching old jQuery markup
- Mars reference mission utcTimeFormat changed to DOY: '%Y-%j %H:%M:%S'
  giving output like '2026-150 21:36:57' instead of ISO format

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* ui: hide observer start time in static mode, show only 'Time' field

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: horizon profile rotation for cylindrical CRS + visibility chart text non-selectable

1. HorizonProfile.py _grid_convergence: same fix as sightmap.py — only
   apply convergence for azimuthal projections (stereo/gnomonic). For
   cylindrical projections (Mars Equidist. Cylindrical), convergence = 0,
   so horizon terrain profile azimuths are now correct.

2. Visibility chart (.sightlineVisWrap): added user-select: none so
   dragging the timeline scrubber doesn't accidentally highlight text.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: horizon profile aspect ratio for geographic CRS + crosshair styling/lag

- HorizonProfile.py: compute per-axis pixel scales (px_scale_x, px_sc…
* fix(SightlineTool): default sightline color ramp uses element color instead of black

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): remove vstBinaryLegend, default opacity 50%

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): crosshair redesign — unfilled circle with N/S/E/W tick lines

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.25-20260608 [version bump]

* fix(SightlineTool): full-size pixelated PNG export, mode-aware CSV/TXT, rename to Results

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): entity lowercase, ISO UTC times in exports, static TXT header fix, smaller crosshair

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(SightlineTool): playback export as animated GIF with basemap overlay

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): header close button margin-right 6px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF export - capture full map container, guard against 0-size canvas

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF export - hide UI controls, add timestamp/progress, lower resolution

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF export - 720px res, UI progress indicator, remove in-frame progress bar

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): GIF progress reaches 100% only after encoding completes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): rewrite CSV exports per mode, remove TXT for playback

- Static: entity,time,lat,lng,visible (single time, binary)
- Playback: entity,time,lat,lng,visible (per-pixel per-frame, each frame's timestamp)
- Composite: entity,start_time,end_time,lat,lng,percent_visible (time range, heatmap)
- Remove TXT grid export option for playback mode

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): reset export dropdown to first item on mode change

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(blueprints): add SightlineTool to Lunar South Pole + new Mars reference mission

- Add time section and SightlineTool config to Lunar South Pole mission
- Create new Reference-Mission-Mars with LayersTool + SightlineTool (Sol observers)
- Reference Mission already has SightlineTool on development branch

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.26-20260608 [version bump]

* feat: register Mars variant in reference mission registries

Add Mars entry to REFERENCE_MISSION_VARIANTS in both:
- API/Backend/Utils/missionTemplates.js (backend)
- configure/src/.../NewMissionModal.js (frontend dropdown)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add lunar ref mission dem data

* Add lunar ref mission dem data 2

* fix: sample bbox perimeter for tile bounds in polar projections

The bounding box clamping in SightlineTool and ViewshedTool
updateDesiredTiles() projected only two corner points of the bbox to
compute tile bounds. In polar stereographic projections (e.g. Lunar
South Pole IAU2000:30120), opposite corners of a lat/lon bbox can map
to the same pixel-space point, producing a degenerate or inverted tile
range — resulting in zero tiles fetched and no sightmap/viewshed
overlay rendered.

Fix: sample 9 evenly-spaced points along each of the four bbox edges
(36 points total) and take the pixel-space envelope. This correctly
handles any map projection.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: projection-aware azimuth lines and source positioning for polar CRS

- locateSource: skip wrapping for custom (non-wrapping) projections so
  distant sources like Sun produce nearly-parallel rays instead of being
  folded into the grid as a nearby point source
- AZ hover line: rotate by local north offset derived from the projection
  so the bearing line points in the correct geographic direction
- Source azimuth lines: same north-offset correction
- Add _localNorthAngle() helper that computes screen-space north direction
  at any lat/lng by projecting a small latitude offset

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): skip observer-centric curvature for distant sources

When the ray source (e.g. Sun) is far outside the DEM grid, the
observer-centric curvature correction in curveData() creates a
circular visibility artifact — the terrain bowl centered on the
observer falsely hides all cells beyond ~350 km.

Skip the curvature correction when dataSource is more than one
grid-width outside the data bounds.  The shadow-plane propagation
already handles parallel-ray geometry correctly on the flat grid.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add backend sightmap endpoint with per-cell ray-march

- New Python endpoint /api/sightmap computes solar illumination grids
  server-side using SPICE for Sun position + DEM ray-marching
- Frontend calls backend instead of fetching tiles + JS shadow-plane algo
- USE_BACKEND_SIGHTMAP const switch to revert to old JS algorithm
- Returns both geographic and projected bounds for polar stereo CRS
- Sightmap overlay uses image-rendering: pixelated for crisp grid cells
- Auto-detects PROJ_DATA path in conda/mamba environments

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.27-20260609 [version bump]

* refactor: consolidate sightmap to backend-only, remove old JS algorithm

- Remove USE_BACKEND_SIGHTMAP flag and all old JS shadow-plane code
- Static path: single sightmap API call (no getbands/ll2aerll)
- Sweep path: batch sightmap API call with times array
- Remove tile-based rendering (makeDataLayer, renderResultToTileData,
  atlas shader, _renderFrameCanvases, SightlineTool_Manager import)
- Rewrite renderHeatmapToMap for imageOverlay (no tile layer)
- Rewrite buildSweepAtlas to pre-render frames as dataURL images
- Rewrite sweepShowFrame to swap imageOverlay URL
- Update export functions for backend grid data structure
- Backend sightmap.py: batch mode (compute_sightmap_batch),
  extracted _ray_march_grid and _compute_bounds helpers
- Express route: dynamic timeout for batch (N*30s, max 30min)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: vectorize ray-march with NumPy + coarse Sun az/el interpolation

Replace pure-Python nested loops in _ray_march_grid() with numpy array
operations.  All output cells are now processed simultaneously at each
march step via broadcasting and fancy indexing.

Optimizations:
1. Vectorized ray march (#1): ~60x speedup on 259×259 grid (47s → 0.8s)
2. Coarse Sun az/el subgrid (#4): compute SPICE-equivalent az/el on a
   10×10 subgrid and bilinearly interpolate to all output cells, reducing
   per-cell coordinate transforms from 67K to ~400.

Helper functions added:
- _vectorized_is_nodata: array-based nodata detection
- _pixels_to_geo_batch: batch pixel→geographic via GDAL CoordinateTransformation
- _sun_azel_batch: pure-numpy geodetic Sun az/el (replaces spiceypy.georec per cell)
- _bilinear_interp_2d: pure-numpy 2D bilinear interpolation (no scipy needed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: store raeRaw/raeAllResults so az/el indicator canvases redraw after mount

The SightlineResults.jsx useEffect triggers on el.raeRaw to redraw
indicator canvases. The static callback was calling updateRAEIndicators
directly (before React mounted the canvases) and only setting raeResults
but not raeRaw/raeAllResults, so the useEffect never fired.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: playback skydome/az-el blocked by stale atlas guard + pixelated CSS selector

- SightlineElement.jsx: replace ed?.atlas checks with ed?.frameImages
  (atlas was the old WebGL texture atlas; refactored code uses frameImages)
- SightlineTool.css: add img.sightmap-pixelated selector (Leaflet applies
  className directly to the <img>, not a wrapper div)
- python-environment.yml: add numba>=0.60.0 for JIT optimization

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: Numba JIT + adaptive march + early cutoff + multiprocessing

sightmap.py performance optimizations on the vectorized ray-march:

1. Numba JIT (@numba.njit): compile inner march loop to native code
   - 259x259 grid: 0.67s cold / 0.12s warm (was 0.79s numpy-only)
2. Adaptive march step: 4x step when margin > 5°, 2x when > 2°
   - Reduces iterations for clearly-illuminated cells
3. Early cutoff: max shadow distance = MAX_TERRAIN_H / tan(el)
   - Skips pointless marching when Sun is high
4. Multiprocessing for batch: Pool(ncpu) across timestamps
   - 12 frames in 0.62s (52ms/frame) vs sequential

Overall: 47.1s → 0.12s per grid = ~380x speedup (warm cache)
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: use Pool initializer for multiprocessing (Windows spawn compat)

On Windows/macOS, multiprocessing uses 'spawn' instead of 'fork',
so module-level globals aren't inherited by worker processes.
Fix: pass shared dict via Pool(initializer=_init_pool_worker, initargs=...)
so each worker sets _mp_shared in its own module namespace.

Tested: 72 timestamps in 1.28s (18ms/frame) on 100x100 grid.
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: five bugs — az offset, playback opacity, progress indicator, mode switch, hover crash

1. sightmap.py: _compute_sun_grid used dem_rows instead of dem_cols for
   column pixel clamping → coarse subgrid positions were wrong → az/el
   off by ~30-45° on non-square DEMs

2. Playback/sweep overlay now inherits el.opacity as initial value
   instead of hardcoded 1.0. Default sweep opacity changed to null;
   applySweepOpacity falls back to el.opacity when ed.opacity unset

3. ProgressButton: added indeterminate mode (sliding bar animation)
   that activates automatically when loading=true and progress<=0.
   Static sightmap generation shows indeterminate; sweep shows %

4. switchElementMode: switching back to static now re-renders the
   cached lastData/lastResultGrid or marks changed=true for auto-regen

5. _onCompositeHover: guard against missing topLeftTile (backend
   sightmap response doesn't include tile-based fields)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct sightmap ray direction for polar stereographic projections

_compute_directions had two bugs in the projected CRS branch:

1. Missing grid convergence rotation: geographic azimuth was used
   directly in pixel space without rotating by the convergence angle
   (longitude for polar stereographic). This caused ~30-45° offset
   at the observer's longitude.

2. Inverted dy sign: cos(az) was used for dy when gt[5]<0, but
   increasing pixel-y = decreasing northing, so the formula must
   negate cos(az) — matching the geographic branch convention.

Also fix _showAzimuthLine/_updateSourceAzimuthLines: the fallback
path (no sweepCenter or indicatorLastDragPoint) now computes
centerLatLng from the map container center so _localNorthAngle
always receives a valid position for the convergence rotation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: add extensive Playwright tests for sightmap API endpoint

Covers:
- Input validation (missing fields, non-finite numerics, NaN, Infinity)
- Path traversal protection on DEM path
- Single-timestamp sightmap computation (structure, grid values, az/el plausibility)
- Batch multi-timestamp computation (results array, az variation)
- Custom Az/El source (el=0 all shadow, el=90 all visible)
- Error handling (invalid SPICE target, nonexistent DEM)
- Consistency (determinism, different times produce different grids)
- Observer height offset comparison
- maxOutputDim clamping
- Projected bounds for polar stereographic CRS

All DEM-dependent tests gracefully skip when Lunar South Pole
mission is not available. AUTH=local mode returns HTML instead
of JSON — tests detect and skip.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): use observer position for azimuth indicator north offset

The azimuth indicator line was computing _localNorthAngle from the map
center (fallback when sweepCenter is unset), which drifts as the user
pans and may be at the south pole where longitude is ~0. The sightmap
shadows are computed from the actual observer position with correct
per-cell convergence, creating a mismatch.

Fix: store the observer lat/lng as sweepCenter in sweepElData when the
static sightmap completes (same as sweep mode already does). This
ensures _showAzimuthLine, _updateSourceAzimuthLines, the crosshair
placement, and the horizon profile all use the correct observer position
for the north angle calculation.

Also add indicatorLastDragPoint fallback to _updateSourceAzimuthLines
(was missing, unlike _showAzimuthLine which already had it).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightmap): correct azimuth CW/CCW convention in _sun_azel_batch

The east vector was computed as normal × north (= Up × N = -East = West),
causing atan2(dot(sun, west), dot(sun, north)) to return counter-clockwise
azimuths. This mirrored the result: geographic 240° CW reported as 120°.

Fix: use north × normal (= N × Up = East) per right-hand rule, matching
SPICE's azccw=False (clockwise from north) convention.

Verified: batch az now matches SPICE exactly (226.2493° vs 226.2493°).
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): use fixed-width fade gradient for visibility segments

The gradient on segment transitions was 30% of the segment width,
making wide segments have long fades and narrow segments short fades.
Now uses a fixed 2% of the total bar width for all transitions,
producing uniform fade-in and fade-out lengths regardless of segment size.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): wire resolution setting to maxOutputDim + gifshot fixes

Resolution dropdown (Low/Med/High/Ultra) now controls the sightmap
grid size sent to the backend:
  Static:  100 / 200 / 400 / 800 px
  Sweep:    50 / 100 / 200 / 400 px

Also:
- Add willReadFrequently to GIF export canvas contexts (suppresses
  Chrome warning about slow getImageData readback)
- Add gifshot progressCallback to update export progress during the
  GIF encoding phase (90→100%) instead of jumping at the end

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): remove dead SightlineTool_Manager and Layer specific DEMs config

SightlineTool_Manager.js was part of the old JS tile-fetching algorithm
(now replaced by the backend sightmap.py endpoint). Nothing imports it.

Remove:
- SightlineTool_Manager.js
- 'Layer specific DEMs' config section (variables.data with demtileurl,
  minZoom, maxNativeZoom, boundingBox) from config.json and all blueprint
  mission configs
- vars.data validation in initialize() — replaced with vars.dem check

The Viewshed tool's separate Manager and its layer-level demtileurl
references are unaffected.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): downsample large DEMs at read time via GDAL decimation

High-res DEMs (e.g. 100m vs 4000m) were read fully into memory even when
the output grid was small, causing:
1. Slow computation (full array I/O + march through many more pixels)
2. Windows pickle truncation on multiprocessing (huge arrays exceed
   pickle buffer limits when serialized to spawn'd worker processes)

Fix: open_dem() now accepts max_working_dim and uses GDAL's ReadAsArray
with buf_xsize/buf_ysize for server-side bilinear decimation. The
geotransform is adjusted to match the resampled pixel grid. Working dim
is set to max(max_output_dim * 4, 1000) — 4x oversampling for terrain
detail in the ray march, capped at native resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): GDAL overview bands, DEM caching, Numba warmup, reduce working_dim

Three optimizations to cut sightmap generation time on large DEMs:

1. GDAL overview bands: If the DEM has pre-computed overviews (COGs),
   read from the overview band directly instead of decimating the full
   raster in memory. Falls back to ReadAsArray(buf_xsize/ysize) for
   DEMs without overviews.

2. DEM caching: Module-level cache keyed by (path, working_dim) avoids
   re-reading the same DEM within a batch run. LRU eviction at 4 entries.

3. Numba JIT warmup: Trigger compilation at module import with a tiny
   2x2 dummy array so the ~5-10s first-compilation cost is paid during
   startup, not during the actual sightmap computation.

4. Reduce working_dim from 4x to 2x output grid (min 500px instead of
   1000px). Halves the march distance and array sizes while maintaining
   sufficient terrain detail for shadow boundary accuracy.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: add timing instrumentation to sightmap.py

Temporary timing logs to stderr at each phase:
- imports, numba_warmup, load_kernels, spice_azel, unload_kernels
- open_dem (with working_dim and actual array size)
- _precompute_grid, _compute_sun_grid, _compute_directions
- _numba_march, total

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: pipe sightmap stderr to Node console for timing visibility

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: include timing data in sightmap JSON response body

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): disk-based .npy cache for decimated DEMs

The timing data showed open_dem takes 12.8s (GDAL reading/decimating a
large DEM) while the actual Numba march takes only 0.065s.  Since each
sightmap call spawns a new Python process, the in-memory cache is lost.

Fix: after the first GDAL decimate, save the resulting numpy array and
geotransform metadata to .npy/.json files next to the source DEM.
Subsequent process invocations load the pre-decimated array via np.load
(~0.01s).  Cache is invalidated if the source DEM file is newer.

Expected improvement: first run ~14s (unchanged), second+ runs ~2s.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): require COG format for large DEMs, remove npy cache

Replace the disk-based .npy cache with a simpler approach: require the
DEM to be a Cloud Optimized GeoTIFF (COG) when decimation is needed.
COGs have internal tiled layout + overview pyramids so GDAL can read at
any target resolution by seeking to the right bytes — no full-file scan.

If the DEM is not a COG and is large enough to need decimation, throw a
clear error with the gdal_translate command to convert it.

Small DEMs that fit within max_working_dim are read directly regardless
of format (no performance issue at small sizes).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: pass through Python error messages to frontend on sightmap failure

When sightmap.py exits with code!=0, the Node handler was returning a
generic 'sightmap computation failed' message. Now it tries to parse
the structured JSON error from stdout first, so the actual error
(e.g. 'DEM is not a COG') reaches the frontend.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sanitize COG error message — no paths or tracebacks in response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove traceback from JSON error response, log to stderr only

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: show actual sightmap error message in toast

- calls.api error callback now parses JSON response body from jqXHR
- SightlineTool error handlers display server error message (e.g. COG)
- No traceback or paths in error responses (security)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove duplicate 'sightmap error' prefix from Python error messages

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): temporarily disable multiprocessing for batch mode

Run all timestamps sequentially in the main process so the DEM stays
in memory and avoids pickle serialization overhead per worker.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightmap): remove multiprocessing and Resolution UI option

- Remove all multiprocessing infrastructure (pool, workers, shared state)
- Batch timestamps run sequentially in the main process
- Remove Resolution dropdown from UI, default to ultra (800px static, 400px sweep)
- Always auto-regenerate on map move (no resolution-gated cutoff)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: temporarily disable Numba JIT for benchmarking comparison

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: re-enable Numba JIT after benchmarking (saves ~6s per frame)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: re-add timing instrumentation to sightmap response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: compute grid convergence analytically, skip GDAL TransformPoints

_compute_directions was calling _pixels_to_geo_batch on all 940K cells
(969x969 grid) to get each cell's longitude for the convergence angle.
This took ~1s (29% of total).

Now computes convergence directly from projected coordinates using
atan2(x - false_easting, sign*(y - false_northing)) — pure numpy
math, no GDAL coordinate transform. Expected: ~0.01s.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: skip kernel unloading + increase coarse subgrid step to 50

- Remove spiceypy.unload() calls — process exits immediately after
  response so OS cleanup is sufficient. Saves ~0.235s.
- Increase COARSE_AZEL_STEP from 10 to 50 — reduces coarse subgrid
  points from ~9400 to ~400 for sun az/el interpolation. Sun position
  varies slowly across the DEM so fewer sample points suffice.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap.py

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: interpolate sun azimuth in sin/cos space to prevent wrap artifacts

When coarse subgrid has azimuth values near the 360°/0° boundary
(e.g. 355° and 5°), linear interpolation produces ~180° — completely
wrong direction that flips shadows. Now interpolates sin(az) and
cos(az) separately, then recovers the angle via atan2. This handles
the circular wrap correctly.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: viewport-aware sightmap — clip DEM read to visible area at native resolution

Frontend sends current map viewport bounds (projected coords) with each
static sightmap request. Backend clips the DEM read to the viewport
intersection, reading at native resolution (capped at maxOutputDim).

When zoomed in, this means the sightmap covers just the visible area
but at much higher resolution than the full-DEM downsampled version.
When zoomed out, viewport encompasses the full DEM and behavior is
unchanged.

For a 30993x30993 DEM zoomed to 1/10th coverage, instead of reading
the full raster decimated to 1600x1600, we read only ~3000x3000 native
pixels for the visible window — higher res and faster.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: compute viewport projected bounds from container corners, not lat/lng bbox

In polar stereographic CRS, the lat/lng bounding box from
getBounds() maps to a distorted region in projected space.
Now samples all 4 container pixel corners, projects each through
the CRS, and takes the envelope for correct projected bounds.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: position sightmap overlay using projected NW/SE corners directly

In polar/rotated CRS, L.latLngBounds normalises by min/max lat/lng,
which shuffles corners — getNorthWest() and getSouthEast() return
points that don't correspond to the projected rectangle's NW and SE.
The overlay is then mispositioned and stretched.

New _projImageOverlay() helper overrides the overlay's _reset method
to compute pixel position from the projected NW (xmin, ymax) and
SE (xmax, ymin) corners via latLngToLayerPoint, bypassing the
normalisation entirely.  Applied to all three overlay creation paths:
static sightmap, heatmap, and sweep frame.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: override _animateZoom on projected overlay to prevent zoom jump

The default _animateZoom reads the normalised L.latLngBounds which
has wrong corners in polar CRS, causing the overlay to briefly jump
to the top-left during zoom transitions.  Now _animateZoom uses the
projected NW corner via _latLngToNewLayerPoint for correct animation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: wire viewport bounds through batch/sweep mode

Frontend sweep call now sends viewportBounds.  Backend
compute_sightmap_batch accepts and passes viewport_bounds
to open_dem so playback also clips to the visible DEM region
at native resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: equalize sweep/static resolution + show 'Sweeping' on progress button

- _resolutionToMaxDim now returns 800 for both modes (was 400 for sweep)
- ProgressButton label shows children text alongside percentage
- SightlineElement shows 'Sweeping'/'Generating' while loading

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: actually commit _resolutionToMaxDim change to equalize sweep/static res

Was missed from the previous commit — sweep was still getting 400 instead of 800.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: re-fetch horizon profile after pan so charts stay in sync

When the user panned, invalidateHorizonCache set _horizonCache to null
but never triggered a re-fetch.  Subsequent scrub or playback frame
changes found the cache empty and either skipped the horizon redraw or
drew the visibility timeline with no profile (marking everything not
visible).

- _onPanEnd now calls invalidateAndRefetch() which re-fetches the
  horizon profile if the graph panel is open.
- _scrubToFrame and updatePlaybackFrame fall back to fetchAndDrawHorizon
  when the cache is null instead of drawing with missing data.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile azimuth for projected CRS (polar stereo)

HorizonProfile.py was marching along pixel/raster-space directions,
treating pixel-up as north.  In polar stereographic the grid north
axis is rotated from true north by the grid convergence angle, so the
profile azimuths were offset and the terrain silhouette appeared
rotated in the chart.

Added _grid_convergence() which computes the convergence at the
observer's projected coordinates via atan2(x - FE, -(y - FN)).  Each
geographic azimuth is now rotated by the convergence before marching
in pixel space, making the profile azimuths true geographic azimuths.
No change for geographic (unprojected) CRS.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile convergence formula for polar stereo

Two bugs in the previous convergence fix:

1. _grid_convergence used atan2(x, -y) unconditionally, but for
   south-pole stereo the sign should be +1 (north is away from
   pole = positive y). Now uses north_sign = -1 for north-pole,
   +1 for south-pole, matching sightmap.py exactly.

2. The convergence was subtracted (geo_az - convergence) when it
   should be added (geo_az + convergence), matching sightmap.py's
   convention where convergence rotates from grid north to true
   north.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: extract rate limiters into shared scripts/rateLimiters.js module

- Create scripts/rateLimiters.js exporting apilimiter, authLimiter, computeLimiter
- Remove inline rateLimit() definitions from scripts/server.js
- Remove authLimiter/computeLimiter from the 's' setup object
- Update Users/routes/users.js: import authLimiter directly, replace late-binding wrapper
- Update Users/setup.js: remove router._authLimiter assignment
- Update Utils/routes/utils.js: import computeLimiter directly, replace all 8 late-binding wrappers
- Update Utils/setup.js: remove router._computeLimiter assignment
- Update unit tests to import from the shared module

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.26-20260609 [version bump]

* fix: use geodesic destination for azimuth lines instead of angle rotation

Replace the _localNorthAngle + screen-space rotation approach with a
direct forward geodesic method: compute a destination lat/lng 1° along
the desired azimuth, project it through Leaflet, and draw the line.

This avoids potential compound angle errors and works correctly for
any CRS because it uses Leaflet's own projection pipeline end-to-end
rather than computing a screen-space north-offset angle and rotating.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: convert reference mission DEMs to Cloud Optimized GeoTIFF (COG)

Both the Earth (USGS SF Hill) and Lunar South Pole (LRO LOLA 4000m)
DEMs are now tiled COGs with deflate compression and overviews.
This ensures consistent behavior with the sightmap COG requirement
and enables fast overview-based reads at any resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add lunar LSMT to chronice, fix TimeUI indicator cleanup and observer time sync

- chronice.py: add lunar LSMT support using SPICE et2lst with observer
  longitude; format: LDAY-NNNNNLHH:MM:SS; reverse conversion via iterative
  refinement
- utils.js: pass optional lng param through to chronice.py
- SightlineTool.js: remove TimeUI indicator on mode switch, cancel sweep,
  resweep start, and pan-end; pass lng from observer point for LSMT observers
- SightlineElement.jsx: update global TimeControl when observer time inputs
  are changed (blur/Enter), fixing Mars SOL time not updating the TimeUI
- Lunar ref mission config: add Moon (LSMT) observer with type=lsmt

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: LSMT lng fallback to map center, hide empty DEM dropdown, Enter key on observer time

- _getObserverLng: fall back to map center when indicatorLastDragPoint is null
- SightlineElement: hide DEM dropdown when no data options configured
- SightlineElement: add onKeyDown Enter handler on observer time inputs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Improve Mars Reference Mission

* chore: bump version to 5.0.28-20260610 [version bump]

* fix: sightmap overlay CRS mismatch for non-custom projections, restore config descriptions

- Only use _projImageOverlay and viewport clipping when the mission uses
  a custom projected CRS (projection.custom=true). For standard longlat/
  Mercator missions (like Mars), the DEM's projected bounds are in a
  different CRS than the map, causing misplaced overlays.
- Restore Layer-specific DEMs config row and improve field descriptions
  in sightline tool config.json (lost during ShadeTool→SightlineTool rename).
- Add sweepColorRamps and observer type examples to descriptionFull.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: restore sightline config descriptions from ShadeTool, remove data row, clear default name

- Remove Layer-specific DEMs config row (previously asked to remove)
- Restore detailed descriptions from old ShadeTool config:
  - Sources: documents name/value properties, dropdown usage, kernel path
  - Observers: documents name/value/frame/body, chronos setup path
  - Default Height: full description of height parameter behavior
  - Observer Time Placeholder: documents format string usage
  - Frame/Body fields: proper SPICE reference descriptions
- Remove 'Sightline N' default element name (now empty)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: observer time input 7hr drift — chronice result parsed as local instead of UTC

Root cause: chronice lmst→utc returns '2026-05-30T21:36:57.975' (no Z suffix).
The old ShadeTool correctly did: result.replace(' ', 'T') + 'Z'
The new code used a regex chain that failed when milliseconds were present
without a trailing Z, leaving the string timezone-ambiguous. new Date()
then parsed it as local time (UTC-7), adding ~7 hours.

Fix: strip milliseconds then unconditionally append Z, matching the old
ShadeTool approach. The /ZZ$/ → Z guard prevents double-Z if chronice
ever returns a Z-suffixed result in the future.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sightmap sun direction for cylindrical projections, 1s time drift, tab-switch regen, add editable time field

1. Sightmap sun direction: _compute_directions now only applies convergence
   rotation for azimuthal projections (stereo/gnomonic). For cylindrical
   projections (Equidistant Cylindrical, Mercator), grid north = geographic
   north so convergence = 0. Previously applied polar-stereo formula to all
   projected CRS, giving ~90deg rotation on Mars DEM.

2. Observer time 1-second drift: restored _lastConvertedMs pattern from old
   ShadeTool. Saves sub-second precision from observer->UTC conversion and
   re-attaches it in UTC->observer reverse conversion for exact round-trips.

3. Tab-switch regeneration: _onTimeChange now tracks _lastGeneratedTime and
   skips if unchanged, preventing redundant sightmap computation when
   TimeControl re-broadcasts the same time on tab refocus.

4. Editable time field (vstOptionTime): restored from old ShadeTool. Shows
   current end time in configured format (DOY, etc), editable on blur/Enter.
   Parses via utcTimeFormat if configured, else appends Z directly.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: match old ShadeTool vstOptionTime styling and DOY format

- CSS matches old ShadeTool exactly: full-width centered input, bold 14px,
  color-p0 bg, color-a1-5 text, transparent border that shows color-c on focus
- Clock icon positioned absolute right (pointer-events: none) as in original
- Structure uses flexbetween wrapper matching old jQuery markup
- Mars reference mission utcTimeFormat changed to DOY: '%Y-%j %H:%M:%S'
  giving output like '2026-150 21:36:57' instead of ISO format

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* ui: hide observer start time in static mode, show only 'Time' field

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: horizon profile rotation for cylindrical CRS + visibility chart text non-selectable

1. HorizonProfile.py _grid_convergence: same fix as sightmap.py — only
   apply convergence for azimuthal projections (stereo/gnomonic). For
   cylindrical projections (Mars Equidist. Cylindrical), convergence = 0,
   so horizon terrain profile azimuths are now correct.

2. Visibility chart (.sightlineVisWrap): added user-select: none so
   dragging the timeline scrubber doesn't accidentally highlight text.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: horizon profile aspect ratio for geographic CRS + crosshair styling/lag

- HorizonProfile.py: compute per-axis pixel scales (px_scale_x, px_scale_y)
  so the march direction accounts for longitude compression at observer
  latitude. For geographic CRS at 38°N, 1° lon ≈ 0.79 × 1° lat in meters;
  without this the march traces wrong physical angles, distorting azimuths.
  Also computes correct per-step physical distance instead of using the
  averaged pixel_scale.

- Crosshair restyled: smaller (8px circle, 5px arms), lime green with
  black borders (box-shadow outline).

- Crosshair converted from raw DOM element to Leaflet DivIcon marker.
  Leaflet handles positioning in its own transform pipeline, eliminating
  the lag that occurred when updating CSS left/top on the move event.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add lime center dot at visible map center while sightline tool is open

Small 6px lime green dot with 1px black border, always at 50%/50% of
the map container (CSS-only positioning, no event tracking needed).
Added on make(), removed on destroy().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: update crosshair position immediately when sweepCenter is set

Previously the crosshair only corrected its position on the next pan
event. Now _updateCrosshairPosition() is called right after sweepCenter
is stored for both static sightmap and batch/sweep completion.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sightmap route security + batch limits + test fix (Devin Review)

- Add SAFE_NAME_RE validation on target, obsRefFrame, obsBody to prevent
  directory traversal via SPICE kernel paths (matches /ll2aerll_bulk).
- Add MAX_TIMES=200 cap on sightmap batch to prevent resource exhaustion.
- Fix E2E test: batch response is a raw JSON array, not { results: [...] }.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: add error handler + headersSent guards on sightmap spawn

Match ll2aerll_bulk pattern: handle child.on('error') and
child.stdin.on('error') to prevent hung responses if Python
fails to start. Add !res.headersSent guards on all response
paths in the close handler.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: get_pixel_scale uses actual array rows instead of ds.RasterYSize

After open_dem decimates a large DEM, gt[5] is scaled but ds still has
the original RasterYSize. Using ds.RasterYSize with the decimated gt
produces a wrong mid_lat for geographic CRS pixel scale. Now accepts
dem_rows directly from dem.shape.

Also: encodeURIComponent the chronice lng argument to match the other
CLI args (consistency with unquote() on the Python side).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct East vector cross product order in sun_azel_at_cell

cross(normal, north) yields West, not East. Changed to
cross(north, normal) to match the batch version _sun_azel_batch.
Currently unused at runtime but prevents future bugs.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove dead code from sightmap.py

Removed unused scalar functions that were superseded by vectorized
equivalents: sun_azel_at_cell (replaced by _sun_azel_batch),
is_nodata (replaced by _vectorized_is_nodata), geo_to_pixel (never
called). Also removed the unused ds parameter from open_dem return
value and _compute_bounds signature — ds was only kept alive for
get_pixel_scale which no longer needs it after the dem_rows fix.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove dead code from SightlineTool frontend

SightlineTool.js: removed showSightlinemapLayers, showSweepLayers,
refreshAllHeatmaps, _nextPow2 — all defined but never called.

SightlineTool_Algorithm.js: removed the entire old client-side
sightline algorithm (sightline, processUp/Down, mask, curveData,
isNoData, compositeResults, calcHeight*, initializeGrids, perOctant)
and their unused imports (jquery, F_, L_, G_). Only
cumulativeVisibility is called externally; all other methods were
from the pre-backend era and superseded by sightmap.py.

SightlineTool_Graphs.js: removed _localNorthAngle, replaced by
the geodesic _destinationPoint + _azimuthEndpoint approach.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: color picker/ramp dropdown clipping + reorder default colors

Remove overflow:hidden from vstSightlineItem, vstSweepCard, and
vstSweepCardsSection so absolutely-positioned color picker palettes
and color ramp dropdowns are no longer clipped by their parent
containers. Add border-radius to headers directly to preserve
rounded corners.

Bump vstColorPalette z-index from 100 to 10000 to match the
ColorRampPicker popup z-index.

Reorder MULTI_SOURCE_COLORS: yellow -> blue -> red -> green
(swapped blue and red positions).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: improve LSMT inverse conversion precision from ~30s to <1s

et2lst returns integer (hr, mn, sc) so one lunar second spans ~29 ET
seconds. The old iterative loop converged to ±1 lunar second, giving
~30s UTC precision. Now uses binary search after the coarse loop to
find the exact ET boundary where the second ticks over, narrowing to
<0.5 ET seconds. Result is placed at the midpoint of the lunar
second window for minimal round-trip error.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: color picker palette uses fixed positioning to escape overflow

The Collapsible panel has overflow:hidden for its open/close
animation, which clips the color picker dropdown. Changed the
palette to position:fixed, computed from the swatch's bounding
rect on click, so it escapes all overflow containers.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: revert fixed positioning, use overflow:visible on open panels

Reverts position:fixed approach. Instead overrides overflow to
visible on open Collapsible panels inside sightlineTool via
[data-open] selector, so the color palette can extend past the
panel boundary while keeping overflow:hidden during animations.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): implement 6 improvements to Sightline tool

1. Combine Playback and Composite Sweeps: always build both heatmap
   and atlas after sweep; mode switching is instantaneous without
   re-running the sweep.

2. Min/Max Distance options: add UI inputs and pass minDistance/
   maxDistance through to sightmap.py ray-march kernel and
   HorizonProfile.py. Includes curvature-based early termination.

3. Better Color Ramps + Fix Transparency Bug: expose sweepColorRamps
   in admin config, reorder defaults, fix evalColor/evalColorWithStops
   discrete bin index calculation (Math.floor -> Math.round).

4. Draggable Horizon Profile Point: crosshair marker is now interactive
   and draggable; on dragend updates indicatorLastDragPoint and refetches
   horizon profile at the new location.

5. Vis Chart - Remove Gradients: remove gradient transition logic from
   drawVisibilityTimeline; uncertain regions shown as occluded.

6. Document Algorithms: add detailed algorithm documentation to
   sightmap.py and HorizonProfile.py headers.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.1.1-20260611 [version bump]

* fix: resolve merge conflict in configure/package.json

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): set mode before sweepShowAllFrames, add distance to horizon cache key

- switchElementMode now sets sightlineMode='playback' before calling
  sweepShowAllFrames, which filters by sightlineMode.
- Horizon profile cache now includes maxDist/minDist so changing distance
  parameters invalidates the cache and triggers a re-fetch.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): address 4 review issues

1. Default color ramps: add inferno + viridis to front of defaults;
   remove sweepColorRamps from all reference mission configs so they
   use the built-in defaults.

2. Mode switching: keep Results section open when switching between
   composite/playback (don't collapse if sweep data exists); call
   sweepShowFrame directly for the element when switching to playback.

3. Crosshair dragging: use Leaflet.Editable (enableEdit/disableEdit)
   instead of L.marker draggable option; listen on 'editable:dragend'.
   Map click handler now only triggers static sightline when element
   is in static mode - prevents unwanted resweep in playback mode.

4. maxDistance fix: backend route (utils.js) was not passing
   minDistance/maxDistance through to the Python sightmap.py stdin
   payload. Added both fields to payloadObj.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): hardcode 6 color ramps, revert crosshair to non-draggable

1. Color ramps: remove custom/configurable sweepColorRamps entirely.
   Hardcode exactly 6 ramps:
   - [transparent, color]
   - [transparent, color, transparent]
   - Inferno
   - Viridis
   - Red → Green (RdYlGn)
   - Black → White (Greys)

2. Crosshair: revert to original non-draggable behavior (interactive: false).
   Remove indicatorLastDragPoint from store and all references.
   Horizon profile and visibility chart now always use the current map
   center — they auto-update on pan end via the existing
   invalidateAndRefetch() call in _onPanEnd.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): reverse B&W ramp to White→Black, single color stop

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): anchor horizon profile to sweep center when available

Revert the horizon profile to use sweepCenter when a sweep exists,
falling back to the current map center when no sweep has been run.
This keeps the horizon chart, entity arcs, and visibility timeline
all consistent with the sweep observer location.

Skip horizon invalidation on pan when anchored to sweep center
to avoid unnecessary backend calls.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightline): add timing instrumentation to sightmap pipeline

Python backend (sightmap.py):
  - Timing for: kernel loading, SPICE az/el, DEM open, grid precompute,
    per-frame sun grid + march + tolist, json.dumps, total
  - Batch response now returns {results, _timing} with per-frame arrays
  - DEM/output dimensions logged for context

Node.js route (utils.js):
  - Log total spawn-to-close time, JSON parse time, stdout size

Frontend (SightlineTool.js):
  - Log API round-trip, grid parsing, heatmap compute, atlas build,
    total frontend processing
  - Console output tagged [Sightmap Timing] / [Sightmap Sweep]

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* TEMP: strip grid data from sightmap response for timing debug

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): cache frame-invariant data in batch mode

Pre-compute and cache across all frames:
- Coarse grid lat/lng (coordinate transform done once, not per-frame)
- Bilinear interpolation weights (indices + weight arrays)
- Ellipsoid geometry for az/el (cell positions, normals, north/east vectors)
- Grid convergence angle for azimuthal projections

Per-frame now only computes:
- Source direction vector subtraction on ~119 coarse points
- 3x bilinear weight-apply (fast matmul, no index recomputation)
- sin/cos for direction on full grid

Expected speedup: source_grid from ~116ms/frame to ~15-25ms/frame
(~5-8x faster for 145 frames: ~17s → ~2-4s)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): eliminate full-grid trig via angle addition formula

Instead of: bilinear interp sin/cos → arctan2 → +convergence → radians → sin/cos
Now:        bilinear interp sin/cos → multiply by cached sin/cos(convergence)

Removes 6 numpy operations on the full 240K-cell grid per frame
(arctan2, where, add, radians, sin, cos) and replaces them with
4 multiply + 2 add operations (cheaper element-wise math).

Also computes coarse az as normalized sin/cos components directly
from the dot products, skipping degrees/radians conversions on
the coarse grid too.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add resolution dropdown + restore grid data

Add viewport-relative resolution control (1×, 0.5×, 0.25×, 0.125×)
to the Display section. Default is 0.25× (medium). The scale factor
multiplies the viewport's longest pixel dimension to produce
maxOutputDim sent to the backend.

- 1× = native (no decimation beyond viewport crop)
- 0.5× = half viewport dims
- 0.25× = quarter (default, balanced speed/quality)
- 0.125× = eighth (fastest, coarsest)

Server-side clamp raised from 800 to 4096 to support 1× on large
viewports.

Also restores grid data in sightmap responses (reverts the TEMP
debug strip).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightmap): scale frame limit by resolution

1× (maxDim≥800): 256 frames
0.5× (maxDim≥400): 512 frames
0.25× (maxDim≥200): 1024 frames
0.125× (maxDim<200): 2048 frames

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightmap): replace times array with startTime/endTime/stepSeconds

Send 3 scalar values instead of potentially 2000+ timestamp strings.
Backend generates timestamps internally from the range.

- Frontend sends ISO timestamps (e.g. '2026-06-11T19:53:00Z') and
  stepSeconds (e.g. 60)
- Node.js route validates the range and computes frame count for
  limit checking
- Python parses ISO start/end, generates time strings with timedelta
- Frame limit raised to 2048 max (at 0.125× resolution)
- Shrinks request payload from ~100KB to ~500B for large sweeps

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): range circles, max distance infinity toggle, viewport padding

- Draw dashed min/max distance circles on map when values are non-zero
  (orange for min, blue for max). Circles update on value change and
  center on sweep observer position when available.

- Add ∞ toggle button on Max Dist row. When active, sends maxDistance=-1
  to backend which skips viewport cropping and reads the full DEM (at
  the appropriate overview level for the resolution setting).

- Add Tooltip on Max Dist label explaining viewport-only vs full DEM
  behavior.

- Backend: when maxDistance > 0, pad viewport bounds by that distance
  in all directions so shadow-casting terrain beyond the visible extent
  is included. When maxDistance=-1 (infinity), viewport_bounds is set
  to None so the full DEM is read.

- Store: add maxDistInfinity per-element field (default false).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: range circles use projected polygon, infinity mode preserves viewport resolution

Range circles:
- Replace L.circle (broken on custom planetary CRS) with L.polygon
  built from 64 vertices computed in projected coordinates via
  crs.project/unproject. Works correctly on polar stereo and any
  custom L.Proj.CRS.

Infinity mode resolution fix:
- Separate DEM read extent from output grid extent. When infinity
  mode is active (maxDistance=-1), the full DEM is loaded at overview
  resolution for shadow tracing, but the output grid is sized to the
  viewport subset only — preserving the same cell density as normal
  mode.
- When maxDistance > 0 (finite padding), DEM read bounds are padded
  by maxDistance in all directions. Output grid still viewport-only.
- Both compute_sightmap and compute_sightmap_batch support
  output_offset_row/col parameters that offset output grid cells
  within the loaded DEM. Coarse subgrid, precompute, and batch
  context all respect the offset so rays trace through the full
  loaded DEM while only evaluating viewport cells.
- Add _compute_bounds_from_proj() to compute geographic bounds from
  projected viewport coords for correct response bounds.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: replace min/max distance fields with DEM Extent dropdown

Remove minDistance, maxDistance, maxDistInfinity fields and range
circles. Replace with a simple 'DEM Extent' dropdown (Viewport / Full
DEM) in the Display section, defaulting to Viewport.

- Viewport: uses only the DEM visible on screen (fast)
- Full DEM: reads the entire raster for distant shadow casting (slower,
  sends maxDistance=-1 to backend)

Removes ~120 lines of range circle rendering code that is no longer
needed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "refactor: replace min/max distance fields with DEM Extent dropdown"

This reverts commit b3d6cc503a170a522f48bdd7f5b21d723b9612e7.

* Revert "fix: range circles use projected polygon, infinity mode preserves viewport resolution"

This reverts commit 6b265aa823e9634e13d5857119826b174360d384.

* Revert "feat(sightline): range circles, max distance infinity toggle, viewport padding"

This reverts commit fd0c5c2262d9c2bccffa69102709be759333c1ad.

* feat(sightline): add Shadow Reach LOD field for distant shadow casting

Add a 'Shadow Reach' input (km) to the Display section that extends
terrain loaded for shadow computation beyond the visible viewport.
When set, the backend reads the viewport DEM at full resolution and
a padded border region at a coarser COG overview level, composites
them into a single array, and marches rays through the full composite
while outputting only the viewport portion.

- New open_dem_composite() reads viewport + low-res border
- Output grid offset support in _precompute_grid_arrays,
  _compute_sun_grid, _precompute_batch_grid_context
- Replaces minDistance/maxDistance with single shadowReach parameter
- Tooltip explains the viewport-extension concept
- Default 0 = viewport only (no performance impact)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): prevent OOM on large shadow reach, clamp to curvature, blur-only regen

- Redesigned open_dem_composite(): single DEM read of the padded
  region at a managed resolution (scales working_dim by viewport/pad
  ratio, capped at 4× max_working_dim) instead of building a massive
  array at viewport pixel scale then upsampling.
- Shadow reach clamped to planetary curvature limit: sqrt(2*R*h_max)
  where h_max=10km. For Moon (R=1737km) this caps at ~186km.
- Shadow Reach input only triggers sightmap regen on blur (unfocus),
  not on every keystroke.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): Enter key triggers regen on Shadow Reach input

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): respect drag order for sightmap z-index, fix drop indicator position

- After rendering a sightmap overlay, re-apply z-order based on the
  current element order in the store so the panel ordering is respected
  regardless of which sightmap finishes loading first.
- Fix drop indicator: changed border-bottom to border-top so the line
  appears above the target element (matching where the drop will place
  the dragged item).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): position-aware drag-and-drop indicator (above/below)

The drop indicator now shows border-top when hovering the upper half
of a target element (insert above) and border-bottom when hovering
the lower half (insert below). The drop logic uses the cursor's Y
position relative to the element midpoint to determine placement.

Applied to both SightlinePanel element cards and SweepSection cards.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): visibility timeline uses sightmap grid pixels, not horizon profile

- Visibility chart now checks the observer's pixel in each frame's
  sightmap grid (lit=1/2 → visible, shadow=0 → occluded) instead of
  comparing source az/el against the horizon profile.
- Observer pixel computed from projected coordinates (using CRS
  projection) when available, falling back to geographic bounds.
- Replaces horizon profile interpolation in drawVisibilityTimeline
  with direct grid pixel lookup via centerVisible.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore(sightline): remove timing logs and fix graph slider performance

- Remove all timing instrumentation from sightmap.py (timing dict,
  perf_counter calls, stderr serialization log, import time)
- Remove _timing from sightmap API responses (single + batch)
- Remove all console.log timing calls from SightlineTool.js
- Fix graph time slider performance: _scrubToFrame was redrawing
  horizon + visibility canvases synchronously AND triggering the
  same redraws again via the scrub callback (sweepShowAllFrames →
  updatePlaybackFrame → requestAnimationFrame). Now _scrubToFrame
  just sets the index and calls the callback, letting
  updatePlaybackFrame handle all redraws in a single rAF.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add distance-based fog shading to horizon profile

Backend (HorizonProfile.py):
- Track distance (meters) to the horizon point at each azimuth
- Output now includes [az, el, dist_m] per sample

Frontend (_drawHorizonCanvas):
- Parse distance from profile data (backward-compatible if missing)
- Replace uniform terrain fill with per-strip vertical fills
- Each strip's opacity mapped via log scale from distance:
  close horizon = opaque, far horizon = transparent
- Falls back to uniform fill if no distance data available

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase horizon profile fog opacity variation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): horizon profile now refreshes after new sweep completes

updatePlaybackFrame was using _horizonCache directly without validating
it against the current sweep center. After a new sweep set a different
sweepCenter, the stale cached profile was still drawn. Now routes through
fetchAndDrawHorizon which validates cache keys (lat/lng/height) and
refetches when the center has changed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add hover tooltip to horizon profile showing azimuth, elevation, and distance

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase horizon profile max radius from 5km to 50km

5km was too short to reach far crater rims on coarser DEMs, causing
the horizon profile to report deeply negative elevation angles where
the ray never found the actual skyline.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase horizon profile max radius to 250km

Also raised the backend cap from 100km to 500km to accommodate.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightline): add log stepping + early termination to horizon profile

Ray march now uses logarithmic step size (1px near, ~11px at 2500px)
so distant terrain is sampled more coarsely. Early termination stops a
ray once the maximum plausible terrain peak (10km, minus curvature)
at the current distance can't beat the already-found max elevation
angle. Together these reduce samples from ~2500/ray to ~200-600/ray
for 250km radius at 100m/pixel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add header with dual-handle log-scale horizon distance slider

Adds a 'Sightline Graphs' title and a dual-handle range slider to the
bottom panel header. The slider controls min/max horizon lookup distance
on a log scale (1m–250km). Dragging either handle invalidates the cache
and refetches the horizon profile with the new range. Default: 100m–250km.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): default horizon range to 1m–250km and add tippy tooltip

Changed min distance default from 100m to 1m. Added tippy tooltip on
the 'Horizon:' label explaining the dual-handle slider controls.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase crosshair circle and center dot radius by 1px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): deduplicate visibility chart time ticks and add user-select:none

When numTicks > frame count, multiple tick positions could round to the
same frame index, producing duplicate labels (e.g. two 'Jan 14 12:00').
Now skips any tick whose frameIdx matches the previous one. Also added
user-select: none to the time labels row.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): align visibility ticks with red slider position

- Cap numTicks to frame count (no more ticks than frames)
- Position ticks using frameIdx/(frameCount-1) — same formula as
  the red time slider — so they always line up exactly

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): set vstTimeStep width to 76px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): extract Sightline into dedicated backend module

Move sightmap and horizon profile endpoints from API/Backend/Utils/ into
a new API/Backend/Sightline/ module with its own setup.js, routes, and
scripts directory.

- POST /api/utils/sightmap → POST /api/sightline/sightmap
- POST /api/utils/gethorizonprofile → POST /api/sightline/horizonprofile
- private/api/sightmap.py → API/Backend/Sightline/scripts/sightmap.py
- private/api/HorizonProfile.py → API/Backend/Sightline/scripts/HorizonProfile.py
- Extract validateMissionsPath into shared API/validateMissionsPath.js
- Update frontend calls.js and E2E tests to use new paths

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): update SPICE kernel relative path after script relocation

The script moved from private/api/ to API/Backend/Sightline/scripts/,
so the relative path to spice/kernels/ needs 4 parent traversals
instead of 2.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs(sightline): update help with detailed algorithm descriptions

- Document sightmap viewshed algorithm: source position via SPICE,
  DEM composite, tangent-plane projection, ray-march viewshed, output grid
- Document horizon profile algorithm: per-azimuth ray march, elevation
  angle tracking, curvature correction, distance recording
- Add parameter tables for both endpoints
- Document performance methods: log stepping, early termination,
  managed resolution, batch streaming, frame limits
- Update SPICE paths to reflect new spice/ directory
- Update visibility timeline description (now pixel-based)
- Add fog shading, hover tooltip, and range slider to horizon profile docs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): extract modules for Indicators, Export, Horizon, Visibility

- Extract SightlineTool_Indicators.js (~689 lines): Az/El canvas gauges, sky dome, mini RAE
- Extract SightlineTool_Export.js (~488 lines): PNG, GIF, CSV, Grid export functions
- Extract SightlineTool_Horizon.js (~574 lines): horizon profile canvas, fog shading, tooltip
- Extract SightlineTool_Visibility.js (~278 lines): visibility timeline bar chart
- Extract RangeSlider reusable component to src/design-system/components/RangeSlider/
- SightlineTool.js reduced from 3082 to 1919 lines (thin delegates to new modules)
- SightlineTool_Graphs.js reduced from 1532 to 981 lines (delegates to Horizon/Visibility)
- Fix shadowReach isFinite validation bug (Devin Review feedback)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): invert color ramp to black→white, add horizon polygon overlay

- Invert WhiteBlack color ramp to BlackWhite (black→white gradient)
- Add faint horizon polygon on 2D map showing the horizon profile outline
  - Updates whenever horizon profile is fetched/redrawn
  - Removed when sightline graphs panel is closed
  - Very faint styling: white outline 0.35 opacity, fill 0.06 opacity

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add horizon polygon toggle checkbox, increase polygon opacity

- Add 'Polygon:' checkbox in graphs header bar (right of horizon range slider)
- Default off; toggling on shows the horizon polygon overlay on the 2D map
- Polygon is more visible: outline 0.6 opacity, fill 0.12 opacity, weight 1.5
- Checkbox state resets to off when graph panel closes
- Uses existing mmgis-checkbox component pattern

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): thicker polygon border (weight 3), add tippy on Polygon label

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): composite hover uses bounds-based lookup instead of tile projection

The _onCompositeHover function was using Globe_.litho.projection tile
coordinates (topLeftTile, tileResolution) which don't exist in the
sightmap data model. Replaced with bounds-based row/col computation
using data._bounds [west, south, east, north] which is what the
sightmap API actually returns.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): update azimuth lines on map pan/zoom

Azimuth SVG overlay uses pixel coordinates from latLngToContainerPoint.
When the map pans, these coordinates become stale but the overlay wasn't
being redrawn. Now listens for 'move' events on the map while the graph
panel is open and redraws the source azimuth lines on every move.
Listener is removed on panel close.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): composite hover uses projBounds for projected CRS maps

When the map uses a custom projected CRS, Leaflet e.latlng coordinates
are in projected meters, not geographic degrees. The hover function now
uses data._projBounds (projected) when in a projected CRS and
data._bounds (geographic) when in standard geographic CRS.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): update polygon tippy text, add user-select:none to labels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): project mouse coords before comparing to projBounds

Leaflet's e.latlng gi…
postgis:18 stores data in a version specific subdir and expects the
volume at /var/lib/postgresql, not /var/lib/postgresql/data. On a fresh install the old path makes the db container crash-loop, so mmgis never starts (container mmgis-db-1 is unhealthy).

Left over from the postgres16 to18 upgrade (#935). Tested with down -v then up -d on a clean volume.
…t, and Config clone (#1001)

* feat(blueprints): add SightlineTool to Lunar South Pole + new Mars reference mission

- Add time section and SightlineTool config to Lunar South Pole mission
- Create new Reference-Mission-Mars with LayersTool + SightlineTool (Sol observers)
- Reference Mission already has SightlineTool on development branch

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.26-20260608 [version bump]

* feat: register Mars variant in reference mission registries

Add Mars entry to REFERENCE_MISSION_VARIANTS in both:
- API/Backend/Utils/missionTemplates.js (backend)
- configure/src/.../NewMissionModal.js (frontend dropdown)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Add lunar ref mission dem data

* Add lunar ref mission dem data 2

* fix: sample bbox perimeter for tile bounds in polar projections

The bounding box clamping in SightlineTool and ViewshedTool
updateDesiredTiles() projected only two corner points of the bbox to
compute tile bounds. In polar stereographic projections (e.g. Lunar
South Pole IAU2000:30120), opposite corners of a lat/lon bbox can map
to the same pixel-space point, producing a degenerate or inverted tile
range — resulting in zero tiles fetched and no sightmap/viewshed
overlay rendered.

Fix: sample 9 evenly-spaced points along each of the four bbox edges
(36 points total) and take the pixel-space envelope. This correctly
handles any map projection.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: projection-aware azimuth lines and source positioning for polar CRS

- locateSource: skip wrapping for custom (non-wrapping) projections so
  distant sources like Sun produce nearly-parallel rays instead of being
  folded into the grid as a nearby point source
- AZ hover line: rotate by local north offset derived from the projection
  so the bearing line points in the correct geographic direction
- Source azimuth lines: same north-offset correction
- Add _localNorthAngle() helper that computes screen-space north direction
  at any lat/lng by projecting a small latitude offset

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(SightlineTool): skip observer-centric curvature for distant sources

When the ray source (e.g. Sun) is far outside the DEM grid, the
observer-centric curvature correction in curveData() creates a
circular visibility artifact — the terrain bowl centered on the
observer falsely hides all cells beyond ~350 km.

Skip the curvature correction when dataSource is more than one
grid-width outside the data bounds.  The shadow-plane propagation
already handles parallel-ray geometry correctly on the flat grid.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add backend sightmap endpoint with per-cell ray-march

- New Python endpoint /api/sightmap computes solar illumination grids
  server-side using SPICE for Sun position + DEM ray-marching
- Frontend calls backend instead of fetching tiles + JS shadow-plane algo
- USE_BACKEND_SIGHTMAP const switch to revert to old JS algorithm
- Returns both geographic and projected bounds for polar stereo CRS
- Sightmap overlay uses image-rendering: pixelated for crisp grid cells
- Auto-detects PROJ_DATA path in conda/mamba environments

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.27-20260609 [version bump]

* refactor: consolidate sightmap to backend-only, remove old JS algorithm

- Remove USE_BACKEND_SIGHTMAP flag and all old JS shadow-plane code
- Static path: single sightmap API call (no getbands/ll2aerll)
- Sweep path: batch sightmap API call with times array
- Remove tile-based rendering (makeDataLayer, renderResultToTileData,
  atlas shader, _renderFrameCanvases, SightlineTool_Manager import)
- Rewrite renderHeatmapToMap for imageOverlay (no tile layer)
- Rewrite buildSweepAtlas to pre-render frames as dataURL images
- Rewrite sweepShowFrame to swap imageOverlay URL
- Update export functions for backend grid data structure
- Backend sightmap.py: batch mode (compute_sightmap_batch),
  extracted _ray_march_grid and _compute_bounds helpers
- Express route: dynamic timeout for batch (N*30s, max 30min)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: vectorize ray-march with NumPy + coarse Sun az/el interpolation

Replace pure-Python nested loops in _ray_march_grid() with numpy array
operations.  All output cells are now processed simultaneously at each
march step via broadcasting and fancy indexing.

Optimizations:
1. Vectorized ray march (#1): ~60x speedup on 259×259 grid (47s → 0.8s)
2. Coarse Sun az/el subgrid (#4): compute SPICE-equivalent az/el on a
   10×10 subgrid and bilinearly interpolate to all output cells, reducing
   per-cell coordinate transforms from 67K to ~400.

Helper functions added:
- _vectorized_is_nodata: array-based nodata detection
- _pixels_to_geo_batch: batch pixel→geographic via GDAL CoordinateTransformation
- _sun_azel_batch: pure-numpy geodetic Sun az/el (replaces spiceypy.georec per cell)
- _bilinear_interp_2d: pure-numpy 2D bilinear interpolation (no scipy needed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: store raeRaw/raeAllResults so az/el indicator canvases redraw after mount

The SightlineResults.jsx useEffect triggers on el.raeRaw to redraw
indicator canvases. The static callback was calling updateRAEIndicators
directly (before React mounted the canvases) and only setting raeResults
but not raeRaw/raeAllResults, so the useEffect never fired.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: playback skydome/az-el blocked by stale atlas guard + pixelated CSS selector

- SightlineElement.jsx: replace ed?.atlas checks with ed?.frameImages
  (atlas was the old WebGL texture atlas; refactored code uses frameImages)
- SightlineTool.css: add img.sightmap-pixelated selector (Leaflet applies
  className directly to the <img>, not a wrapper div)
- python-environment.yml: add numba>=0.60.0 for JIT optimization

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: Numba JIT + adaptive march + early cutoff + multiprocessing

sightmap.py performance optimizations on the vectorized ray-march:

1. Numba JIT (@numba.njit): compile inner march loop to native code
   - 259x259 grid: 0.67s cold / 0.12s warm (was 0.79s numpy-only)
2. Adaptive march step: 4x step when margin > 5°, 2x when > 2°
   - Reduces iterations for clearly-illuminated cells
3. Early cutoff: max shadow distance = MAX_TERRAIN_H / tan(el)
   - Skips pointless marching when Sun is high
4. Multiprocessing for batch: Pool(ncpu) across timestamps
   - 12 frames in 0.62s (52ms/frame) vs sequential

Overall: 47.1s → 0.12s per grid = ~380x speedup (warm cache)
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: use Pool initializer for multiprocessing (Windows spawn compat)

On Windows/macOS, multiprocessing uses 'spawn' instead of 'fork',
so module-level globals aren't inherited by worker processes.
Fix: pass shared dict via Pool(initializer=_init_pool_worker, initargs=...)
so each worker sets _mp_shared in its own module namespace.

Tested: 72 timestamps in 1.28s (18ms/frame) on 100x100 grid.
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: five bugs — az offset, playback opacity, progress indicator, mode switch, hover crash

1. sightmap.py: _compute_sun_grid used dem_rows instead of dem_cols for
   column pixel clamping → coarse subgrid positions were wrong → az/el
   off by ~30-45° on non-square DEMs

2. Playback/sweep overlay now inherits el.opacity as initial value
   instead of hardcoded 1.0. Default sweep opacity changed to null;
   applySweepOpacity falls back to el.opacity when ed.opacity unset

3. ProgressButton: added indeterminate mode (sliding bar animation)
   that activates automatically when loading=true and progress<=0.
   Static sightmap generation shows indeterminate; sweep shows %

4. switchElementMode: switching back to static now re-renders the
   cached lastData/lastResultGrid or marks changed=true for auto-regen

5. _onCompositeHover: guard against missing topLeftTile (backend
   sightmap response doesn't include tile-based fields)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct sightmap ray direction for polar stereographic projections

_compute_directions had two bugs in the projected CRS branch:

1. Missing grid convergence rotation: geographic azimuth was used
   directly in pixel space without rotating by the convergence angle
   (longitude for polar stereographic). This caused ~30-45° offset
   at the observer's longitude.

2. Inverted dy sign: cos(az) was used for dy when gt[5]<0, but
   increasing pixel-y = decreasing northing, so the formula must
   negate cos(az) — matching the geographic branch convention.

Also fix _showAzimuthLine/_updateSourceAzimuthLines: the fallback
path (no sweepCenter or indicatorLastDragPoint) now computes
centerLatLng from the map container center so _localNorthAngle
always receives a valid position for the convergence rotation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: add extensive Playwright tests for sightmap API endpoint

Covers:
- Input validation (missing fields, non-finite numerics, NaN, Infinity)
- Path traversal protection on DEM path
- Single-timestamp sightmap computation (structure, grid values, az/el plausibility)
- Batch multi-timestamp computation (results array, az variation)
- Custom Az/El source (el=0 all shadow, el=90 all visible)
- Error handling (invalid SPICE target, nonexistent DEM)
- Consistency (determinism, different times produce different grids)
- Observer height offset comparison
- maxOutputDim clamping
- Projected bounds for polar stereographic CRS

All DEM-dependent tests gracefully skip when Lunar South Pole
mission is not available. AUTH=local mode returns HTML instead
of JSON — tests detect and skip.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): use observer position for azimuth indicator north offset

The azimuth indicator line was computing _localNorthAngle from the map
center (fallback when sweepCenter is unset), which drifts as the user
pans and may be at the south pole where longitude is ~0. The sightmap
shadows are computed from the actual observer position with correct
per-cell convergence, creating a mismatch.

Fix: store the observer lat/lng as sweepCenter in sweepElData when the
static sightmap completes (same as sweep mode already does). This
ensures _showAzimuthLine, _updateSourceAzimuthLines, the crosshair
placement, and the horizon profile all use the correct observer position
for the north angle calculation.

Also add indicatorLastDragPoint fallback to _updateSourceAzimuthLines
(was missing, unlike _showAzimuthLine which already had it).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightmap): correct azimuth CW/CCW convention in _sun_azel_batch

The east vector was computed as normal × north (= Up × N = -East = West),
causing atan2(dot(sun, west), dot(sun, north)) to return counter-clockwise
azimuths. This mirrored the result: geographic 240° CW reported as 120°.

Fix: use north × normal (= N × Up = East) per right-hand rule, matching
SPICE's azccw=False (clockwise from north) convention.

Verified: batch az now matches SPICE exactly (226.2493° vs 226.2493°).
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): use fixed-width fade gradient for visibility segments

The gradient on segment transitions was 30% of the segment width,
making wide segments have long fades and narrow segments short fades.
Now uses a fixed 2% of the total bar width for all transitions,
producing uniform fade-in and fade-out lengths regardless of segment size.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): wire resolution setting to maxOutputDim + gifshot fixes

Resolution dropdown (Low/Med/High/Ultra) now controls the sightmap
grid size sent to the backend:
  Static:  100 / 200 / 400 / 800 px
  Sweep:    50 / 100 / 200 / 400 px

Also:
- Add willReadFrequently to GIF export canvas contexts (suppresses
  Chrome warning about slow getImageData readback)
- Add gifshot progressCallback to update export progress during the
  GIF encoding phase (90→100%) instead of jumping at the end

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): remove dead SightlineTool_Manager and Layer specific DEMs config

SightlineTool_Manager.js was part of the old JS tile-fetching algorithm
(now replaced by the backend sightmap.py endpoint). Nothing imports it.

Remove:
- SightlineTool_Manager.js
- 'Layer specific DEMs' config section (variables.data with demtileurl,
  minZoom, maxNativeZoom, boundingBox) from config.json and all blueprint
  mission configs
- vars.data validation in initialize() — replaced with vars.dem check

The Viewshed tool's separate Manager and its layer-level demtileurl
references are unaffected.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): downsample large DEMs at read time via GDAL decimation

High-res DEMs (e.g. 100m vs 4000m) were read fully into memory even when
the output grid was small, causing:
1. Slow computation (full array I/O + march through many more pixels)
2. Windows pickle truncation on multiprocessing (huge arrays exceed
   pickle buffer limits when serialized to spawn'd worker processes)

Fix: open_dem() now accepts max_working_dim and uses GDAL's ReadAsArray
with buf_xsize/buf_ysize for server-side bilinear decimation. The
geotransform is adjusted to match the resampled pixel grid. Working dim
is set to max(max_output_dim * 4, 1000) — 4x oversampling for terrain
detail in the ray march, capped at native resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): GDAL overview bands, DEM caching, Numba warmup, reduce working_dim

Three optimizations to cut sightmap generation time on large DEMs:

1. GDAL overview bands: If the DEM has pre-computed overviews (COGs),
   read from the overview band directly instead of decimating the full
   raster in memory. Falls back to ReadAsArray(buf_xsize/ysize) for
   DEMs without overviews.

2. DEM caching: Module-level cache keyed by (path, working_dim) avoids
   re-reading the same DEM within a batch run. LRU eviction at 4 entries.

3. Numba JIT warmup: Trigger compilation at module import with a tiny
   2x2 dummy array so the ~5-10s first-compilation cost is paid during
   startup, not during the actual sightmap computation.

4. Reduce working_dim from 4x to 2x output grid (min 500px instead of
   1000px). Halves the march distance and array sizes while maintaining
   sufficient terrain detail for shadow boundary accuracy.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: add timing instrumentation to sightmap.py

Temporary timing logs to stderr at each phase:
- imports, numba_warmup, load_kernels, spice_azel, unload_kernels
- open_dem (with working_dim and actual array size)
- _precompute_grid, _compute_sun_grid, _compute_directions
- _numba_march, total

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: pipe sightmap stderr to Node console for timing visibility

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: include timing data in sightmap JSON response body

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): disk-based .npy cache for decimated DEMs

The timing data showed open_dem takes 12.8s (GDAL reading/decimating a
large DEM) while the actual Numba march takes only 0.065s.  Since each
sightmap call spawns a new Python process, the in-memory cache is lost.

Fix: after the first GDAL decimate, save the resulting numpy array and
geotransform metadata to .npy/.json files next to the source DEM.
Subsequent process invocations load the pre-decimated array via np.load
(~0.01s).  Cache is invalidated if the source DEM file is newer.

Expected improvement: first run ~14s (unchanged), second+ runs ~2s.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): require COG format for large DEMs, remove npy cache

Replace the disk-based .npy cache with a simpler approach: require the
DEM to be a Cloud Optimized GeoTIFF (COG) when decimation is needed.
COGs have internal tiled layout + overview pyramids so GDAL can read at
any target resolution by seeking to the right bytes — no full-file scan.

If the DEM is not a COG and is large enough to need decimation, throw a
clear error with the gdal_translate command to convert it.

Small DEMs that fit within max_working_dim are read directly regardless
of format (no performance issue at small sizes).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: pass through Python error messages to frontend on sightmap failure

When sightmap.py exits with code!=0, the Node handler was returning a
generic 'sightmap computation failed' message. Now it tries to parse
the structured JSON error from stdout first, so the actual error
(e.g. 'DEM is not a COG') reaches the frontend.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sanitize COG error message — no paths or tracebacks in response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove traceback from JSON error response, log to stderr only

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: show actual sightmap error message in toast

- calls.api error callback now parses JSON response body from jqXHR
- SightlineTool error handlers display server error message (e.g. COG)
- No traceback or paths in error responses (security)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove duplicate 'sightmap error' prefix from Python error messages

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): temporarily disable multiprocessing for batch mode

Run all timestamps sequentially in the main process so the DEM stays
in memory and avoids pickle serialization overhead per worker.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightmap): remove multiprocessing and Resolution UI option

- Remove all multiprocessing infrastructure (pool, workers, shared state)
- Batch timestamps run sequentially in the main process
- Remove Resolution dropdown from UI, default to ultra (800px static, 400px sweep)
- Always auto-regenerate on map move (no resolution-gated cutoff)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: temporarily disable Numba JIT for benchmarking comparison

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: re-enable Numba JIT after benchmarking (saves ~6s per frame)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: re-add timing instrumentation to sightmap response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: compute grid convergence analytically, skip GDAL TransformPoints

_compute_directions was calling _pixels_to_geo_batch on all 940K cells
(969x969 grid) to get each cell's longitude for the convergence angle.
This took ~1s (29% of total).

Now computes convergence directly from projected coordinates using
atan2(x - false_easting, sign*(y - false_northing)) — pure numpy
math, no GDAL coordinate transform. Expected: ~0.01s.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: skip kernel unloading + increase coarse subgrid step to 50

- Remove spiceypy.unload() calls — process exits immediately after
  response so OS cleanup is sufficient. Saves ~0.235s.
- Increase COARSE_AZEL_STEP from 10 to 50 — reduces coarse subgrid
  points from ~9400 to ~400 for sun az/el interpolation. Sun position
  varies slowly across the DEM so fewer sample points suffice.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap.py

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: interpolate sun azimuth in sin/cos space to prevent wrap artifacts

When coarse subgrid has azimuth values near the 360°/0° boundary
(e.g. 355° and 5°), linear interpolation produces ~180° — completely
wrong direction that flips shadows. Now interpolates sin(az) and
cos(az) separately, then recovers the angle via atan2. This handles
the circular wrap correctly.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: viewport-aware sightmap — clip DEM read to visible area at native resolution

Frontend sends current map viewport bounds (projected coords) with each
static sightmap request. Backend clips the DEM read to the viewport
intersection, reading at native resolution (capped at maxOutputDim).

When zoomed in, this means the sightmap covers just the visible area
but at much higher resolution than the full-DEM downsampled version.
When zoomed out, viewport encompasses the full DEM and behavior is
unchanged.

For a 30993x30993 DEM zoomed to 1/10th coverage, instead of reading
the full raster decimated to 1600x1600, we read only ~3000x3000 native
pixels for the visible window — higher res and faster.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: compute viewport projected bounds from container corners, not lat/lng bbox

In polar stereographic CRS, the lat/lng bounding box from
getBounds() maps to a distorted region in projected space.
Now samples all 4 container pixel corners, projects each through
the CRS, and takes the envelope for correct projected bounds.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: position sightmap overlay using projected NW/SE corners directly

In polar/rotated CRS, L.latLngBounds normalises by min/max lat/lng,
which shuffles corners — getNorthWest() and getSouthEast() return
points that don't correspond to the projected rectangle's NW and SE.
The overlay is then mispositioned and stretched.

New _projImageOverlay() helper overrides the overlay's _reset method
to compute pixel position from the projected NW (xmin, ymax) and
SE (xmax, ymin) corners via latLngToLayerPoint, bypassing the
normalisation entirely.  Applied to all three overlay creation paths:
static sightmap, heatmap, and sweep frame.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: override _animateZoom on projected overlay to prevent zoom jump

The default _animateZoom reads the normalised L.latLngBounds which
has wrong corners in polar CRS, causing the overlay to briefly jump
to the top-left during zoom transitions.  Now _animateZoom uses the
projected NW corner via _latLngToNewLayerPoint for correct animation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: wire viewport bounds through batch/sweep mode

Frontend sweep call now sends viewportBounds.  Backend
compute_sightmap_batch accepts and passes viewport_bounds
to open_dem so playback also clips to the visible DEM region
at native resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: equalize sweep/static resolution + show 'Sweeping' on progress button

- _resolutionToMaxDim now returns 800 for both modes (was 400 for sweep)
- ProgressButton label shows children text alongside percentage
- SightlineElement shows 'Sweeping'/'Generating' while loading

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: actually commit _resolutionToMaxDim change to equalize sweep/static res

Was missed from the previous commit — sweep was still getting 400 instead of 800.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: re-fetch horizon profile after pan so charts stay in sync

When the user panned, invalidateHorizonCache set _horizonCache to null
but never triggered a re-fetch.  Subsequent scrub or playback frame
changes found the cache empty and either skipped the horizon redraw or
drew the visibility timeline with no profile (marking everything not
visible).

- _onPanEnd now calls invalidateAndRefetch() which re-fetches the
  horizon profile if the graph panel is open.
- _scrubToFrame and updatePlaybackFrame fall back to fetchAndDrawHorizon
  when the cache is null instead of drawing with missing data.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile azimuth for projected CRS (polar stereo)

HorizonProfile.py was marching along pixel/raster-space directions,
treating pixel-up as north.  In polar stereographic the grid north
axis is rotated from true north by the grid convergence angle, so the
profile azimuths were offset and the terrain silhouette appeared
rotated in the chart.

Added _grid_convergence() which computes the convergence at the
observer's projected coordinates via atan2(x - FE, -(y - FN)).  Each
geographic azimuth is now rotated by the convergence before marching
in pixel space, making the profile azimuths true geographic azimuths.
No change for geographic (unprojected) CRS.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile convergence formula for polar stereo

Two bugs in the previous convergence fix:

1. _grid_convergence used atan2(x, -y) unconditionally, but for
   south-pole stereo the sign should be +1 (north is away from
   pole = positive y). Now uses north_sign = -1 for north-pole,
   +1 for south-pole, matching sightmap.py exactly.

2. The convergence was subtracted (geo_az - convergence) when it
   should be added (geo_az + convergence), matching sightmap.py's
   convention where convergence rotates from grid north to true
   north.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: extract rate limiters into shared scripts/rateLimiters.js module

- Create scripts/rateLimiters.js exporting apilimiter, authLimiter, computeLimiter
- Remove inline rateLimit() definitions from scripts/server.js
- Remove authLimiter/computeLimiter from the 's' setup object
- Update Users/routes/users.js: import authLimiter directly, replace late-binding wrapper
- Update Users/setup.js: remove router._authLimiter assignment
- Update Utils/routes/utils.js: import computeLimiter directly, replace all 8 late-binding wrappers
- Update Utils/setup.js: remove router._computeLimiter assignment
- Update unit tests to import from the shared module

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.26-20260609 [version bump]

* fix: use geodesic destination for azimuth lines instead of angle rotation

Replace the _localNorthAngle + screen-space rotation approach with a
direct forward geodesic method: compute a destination lat/lng 1° along
the desired azimuth, project it through Leaflet, and draw the line.

This avoids potential compound angle errors and works correctly for
any CRS because it uses Leaflet's own projection pipeline end-to-end
rather than computing a screen-space north-offset angle and rotating.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: convert reference mission DEMs to Cloud Optimized GeoTIFF (COG)

Both the Earth (USGS SF Hill) and Lunar South Pole (LRO LOLA 4000m)
DEMs are now tiled COGs with deflate compression and overviews.
This ensures consistent behavior with the sightmap COG requirement
and enables fast overview-based reads at any resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add lunar LSMT to chronice, fix TimeUI indicator cleanup and observer time sync

- chronice.py: add lunar LSMT support using SPICE et2lst with observer
  longitude; format: LDAY-NNNNNLHH:MM:SS; reverse conversion via iterative
  refinement
- utils.js: pass optional lng param through to chronice.py
- SightlineTool.js: remove TimeUI indicator on mode switch, cancel sweep,
  resweep start, and pan-end; pass lng from observer point for LSMT observers
- SightlineElement.jsx: update global TimeControl when observer time inputs
  are changed (blur/Enter), fixing Mars SOL time not updating the TimeUI
- Lunar ref mission config: add Moon (LSMT) observer with type=lsmt

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: LSMT lng fallback to map center, hide empty DEM dropdown, Enter key on observer time

- _getObserverLng: fall back to map center when indicatorLastDragPoint is null
- SightlineElement: hide DEM dropdown when no data options configured
- SightlineElement: add onKeyDown Enter handler on observer time inputs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Improve Mars Reference Mission

* chore: bump version to 5.0.28-20260610 [version bump]

* fix: sightmap overlay CRS mismatch for non-custom projections, restore config descriptions

- Only use _projImageOverlay and viewport clipping when the mission uses
  a custom projected CRS (projection.custom=true). For standard longlat/
  Mercator missions (like Mars), the DEM's projected bounds are in a
  different CRS than the map, causing misplaced overlays.
- Restore Layer-specific DEMs config row and improve field descriptions
  in sightline tool config.json (lost during ShadeTool→SightlineTool rename).
- Add sweepColorRamps and observer type examples to descriptionFull.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: restore sightline config descriptions from ShadeTool, remove data row, clear default name

- Remove Layer-specific DEMs config row (previously asked to remove)
- Restore detailed descriptions from old ShadeTool config:
  - Sources: documents name/value properties, dropdown usage, kernel path
  - Observers: documents name/value/frame/body, chronos setup path
  - Default Height: full description of height parameter behavior
  - Observer Time Placeholder: documents format string usage
  - Frame/Body fields: proper SPICE reference descriptions
- Remove 'Sightline N' default element name (now empty)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: observer time input 7hr drift — chronice result parsed as local instead of UTC

Root cause: chronice lmst→utc returns '2026-05-30T21:36:57.975' (no Z suffix).
The old ShadeTool correctly did: result.replace(' ', 'T') + 'Z'
The new code used a regex chain that failed when milliseconds were present
without a trailing Z, leaving the string timezone-ambiguous. new Date()
then parsed it as local time (UTC-7), adding ~7 hours.

Fix: strip milliseconds then unconditionally append Z, matching the old
ShadeTool approach. The /ZZ$/ → Z guard prevents double-Z if chronice
ever returns a Z-suffixed result in the future.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sightmap sun direction for cylindrical projections, 1s time drift, tab-switch regen, add editable time field

1. Sightmap sun direction: _compute_directions now only applies convergence
   rotation for azimuthal projections (stereo/gnomonic). For cylindrical
   projections (Equidistant Cylindrical, Mercator), grid north = geographic
   north so convergence = 0. Previously applied polar-stereo formula to all
   projected CRS, giving ~90deg rotation on Mars DEM.

2. Observer time 1-second drift: restored _lastConvertedMs pattern from old
   ShadeTool. Saves sub-second precision from observer->UTC conversion and
   re-attaches it in UTC->observer reverse conversion for exact round-trips.

3. Tab-switch regeneration: _onTimeChange now tracks _lastGeneratedTime and
   skips if unchanged, preventing redundant sightmap computation when
   TimeControl re-broadcasts the same time on tab refocus.

4. Editable time field (vstOptionTime): restored from old ShadeTool. Shows
   current end time in configured format (DOY, etc), editable on blur/Enter.
   Parses via utcTimeFormat if configured, else appends Z directly.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: match old ShadeTool vstOptionTime styling and DOY format

- CSS matches old ShadeTool exactly: full-width centered input, bold 14px,
  color-p0 bg, color-a1-5 text, transparent border that shows color-c on focus
- Clock icon positioned absolute right (pointer-events: none) as in original
- Structure uses flexbetween wrapper matching old jQuery markup
- Mars reference mission utcTimeFormat changed to DOY: '%Y-%j %H:%M:%S'
  giving output like '2026-150 21:36:57' instead of ISO format

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* ui: hide observer start time in static mode, show only 'Time' field

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: horizon profile rotation for cylindrical CRS + visibility chart text non-selectable

1. HorizonProfile.py _grid_convergence: same fix as sightmap.py — only
   apply convergence for azimuthal projections (stereo/gnomonic). For
   cylindrical projections (Mars Equidist. Cylindrical), convergence = 0,
   so horizon terrain profile azimuths are now correct.

2. Visibility chart (.sightlineVisWrap): added user-select: none so
   dragging the timeline scrubber doesn't accidentally highlight text.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: horizon profile aspect ratio for geographic CRS + crosshair styling/lag

- HorizonProfile.py: compute per-axis pixel scales (px_scale_x, px_scale_y)
  so the march direction accounts for longitude compression at observer
  latitude. For geographic CRS at 38°N, 1° lon ≈ 0.79 × 1° lat in meters;
  without this the march traces wrong physical angles, distorting azimuths.
  Also computes correct per-step physical distance instead of using the
  averaged pixel_scale.

- Crosshair restyled: smaller (8px circle, 5px arms), lime green with
  black borders (box-shadow outline).

- Crosshair converted from raw DOM element to Leaflet DivIcon marker.
  Leaflet handles positioning in its own transform pipeline, eliminating
  the lag that occurred when updating CSS left/top on the move event.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add lime center dot at visible map center while sightline tool is open

Small 6px lime green dot with 1px black border, always at 50%/50% of
the map container (CSS-only positioning, no event tracking needed).
Added on make(), removed on destroy().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: update crosshair position immediately when sweepCenter is set

Previously the crosshair only corrected its position on the next pan
event. Now _updateCrosshairPosition() is called right after sweepCenter
is stored for both static sightmap and batch/sweep completion.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sightmap route security + batch limits + test fix (Devin Review)

- Add SAFE_NAME_RE validation on target, obsRefFrame, obsBody to prevent
  directory traversal via SPICE kernel paths (matches /ll2aerll_bulk).
- Add MAX_TIMES=200 cap on sightmap batch to prevent resource exhaustion.
- Fix E2E test: batch response is a raw JSON array, not { results: [...] }.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: add error handler + headersSent guards on sightmap spawn

Match ll2aerll_bulk pattern: handle child.on('error') and
child.stdin.on('error') to prevent hung responses if Python
fails to start. Add !res.headersSent guards on all response
paths in the close handler.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: get_pixel_scale uses actual array rows instead of ds.RasterYSize

After open_dem decimates a large DEM, gt[5] is scaled but ds still has
the original RasterYSize. Using ds.RasterYSize with the decimated gt
produces a wrong mid_lat for geographic CRS pixel scale. Now accepts
dem_rows directly from dem.shape.

Also: encodeURIComponent the chronice lng argument to match the other
CLI args (consistency with unquote() on the Python side).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct East vector cross product order in sun_azel_at_cell

cross(normal, north) yields West, not East. Changed to
cross(north, normal) to match the batch version _sun_azel_batch.
Currently unused at runtime but prevents future bugs.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove dead code from sightmap.py

Removed unused scalar functions that were superseded by vectorized
equivalents: sun_azel_at_cell (replaced by _sun_azel_batch),
is_nodata (replaced by _vectorized_is_nodata), geo_to_pixel (never
called). Also removed the unused ds parameter from open_dem return
value and _compute_bounds signature — ds was only kept alive for
get_pixel_scale which no longer needs it after the dem_rows fix.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove dead code from SightlineTool frontend

SightlineTool.js: removed showSightlinemapLayers, showSweepLayers,
refreshAllHeatmaps, _nextPow2 — all defined but never called.

SightlineTool_Algorithm.js: removed the entire old client-side
sightline algorithm (sightline, processUp/Down, mask, curveData,
isNoData, compositeResults, calcHeight*, initializeGrids, perOctant)
and their unused imports (jquery, F_, L_, G_). Only
cumulativeVisibility is called externally; all other methods were
from the pre-backend era and superseded by sightmap.py.

SightlineTool_Graphs.js: removed _localNorthAngle, replaced by
the geodesic _destinationPoint + _azimuthEndpoint approach.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: color picker/ramp dropdown clipping + reorder default colors

Remove overflow:hidden from vstSightlineItem, vstSweepCard, and
vstSweepCardsSection so absolutely-positioned color picker palettes
and color ramp dropdowns are no longer clipped by their parent
containers. Add border-radius to headers directly to preserve
rounded corners.

Bump vstColorPalette z-index from 100 to 10000 to match the
ColorRampPicker popup z-index.

Reorder MULTI_SOURCE_COLORS: yellow -> blue -> red -> green
(swapped blue and red positions).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: improve LSMT inverse conversion precision from ~30s to <1s

et2lst returns integer (hr, mn, sc) so one lunar second spans ~29 ET
seconds. The old iterative loop converged to ±1 lunar second, giving
~30s UTC precision. Now uses binary search after the coarse loop to
find the exact ET boundary where the second ticks over, narrowing to
<0.5 ET seconds. Result is placed at the midpoint of the lunar
second window for minimal round-trip error.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: color picker palette uses fixed positioning to escape overflow

The Collapsible panel has overflow:hidden for its open/close
animation, which clips the color picker dropdown. Changed the
palette to position:fixed, computed from the swatch's bounding
rect on click, so it escapes all overflow containers.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: revert fixed positioning, use overflow:visible on open panels

Reverts position:fixed approach. Instead overrides overflow to
visible on open Collapsible panels inside sightlineTool via
[data-open] selector, so the color palette can extend past the
panel boundary while keeping overflow:hidden during animations.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): implement 6 improvements to Sightline tool

1. Combine Playback and Composite Sweeps: always build both heatmap
   and atlas after sweep; mode switching is instantaneous without
   re-running the sweep.

2. Min/Max Distance options: add UI inputs and pass minDistance/
   maxDistance through to sightmap.py ray-march kernel and
   HorizonProfile.py. Includes curvature-based early termination.

3. Better Color Ramps + Fix Transparency Bug: expose sweepColorRamps
   in admin config, reorder defaults, fix evalColor/evalColorWithStops
   discrete bin index calculation (Math.floor -> Math.round).

4. Draggable Horizon Profile Point: crosshair marker is now interactive
   and draggable; on dragend updates indicatorLastDragPoint and refetches
   horizon profile at the new location.

5. Vis Chart - Remove Gradients: remove gradient transition logic from
   drawVisibilityTimeline; uncertain regions shown as occluded.

6. Document Algorithms: add detailed algorithm documentation to
   sightmap.py and HorizonProfile.py headers.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.1.1-20260611 [version bump]

* fix: resolve merge conflict in configure/package.json

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): set mode before sweepShowAllFrames, add distance to horizon cache key

- switchElementMode now sets sightlineMode='playback' before calling
  sweepShowAllFrames, which filters by sightlineMode.
- Horizon profile cache now includes maxDist/minDist so changing distance
  parameters invalidates the cache and triggers a re-fetch.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): address 4 review issues

1. Default color ramps: add inferno + viridis to front of defaults;
   remove sweepColorRamps from all reference mission configs so they
   use the built-in defaults.

2. Mode switching: keep Results section open when switching between
   composite/playback (don't collapse if sweep data exists); call
   sweepShowFrame directly for the element when switching to playback.

3. Crosshair dragging: use Leaflet.Editable (enableEdit/disableEdit)
   instead of L.marker draggable option; listen on 'editable:dragend'.
   Map click handler now only triggers static sightline when element
   is in static mode - prevents unwanted resweep in playback mode.

4. maxDistance fix: backend route (utils.js) was not passing
   minDistance/maxDistance through to the Python sightmap.py stdin
   payload. Added both fields to payloadObj.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): hardcode 6 color ramps, revert crosshair to non-draggable

1. Color ramps: remove custom/configurable sweepColorRamps entirely.
   Hardcode exactly 6 ramps:
   - [transparent, color]
   - [transparent, color, transparent]
   - Inferno
   - Viridis
   - Red → Green (RdYlGn)
   - Black → White (Greys)

2. Crosshair: revert to original non-draggable behavior (interactive: false).
   Remove indicatorLastDragPoint from store and all references.
   Horizon profile and visibility chart now always use the current map
   center — they auto-update on pan end via the existing
   invalidateAndRefetch() call in _onPanEnd.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): reverse B&W ramp to White→Black, single color stop

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): anchor horizon profile to sweep center when available

Revert the horizon profile to use sweepCenter when a sweep exists,
falling back to the current map center when no sweep has been run.
This keeps the horizon chart, entity arcs, and visibility timeline
all consistent with the sweep observer location.

Skip horizon invalidation on pan when anchored to sweep center
to avoid unnecessary backend calls.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightline): add timing instrumentation to sightmap pipeline

Python backend (sightmap.py):
  - Timing for: kernel loading, SPICE az/el, DEM open, grid precompute,
    per-frame sun grid + march + tolist, json.dumps, total
  - Batch response now returns {results, _timing} with per-frame arrays
  - DEM/output dimensions logged for context

Node.js route (utils.js):
  - Log total spawn-to-close time, JSON parse time, stdout size

Frontend (SightlineTool.js):
  - Log API round-trip, grid parsing, heatmap compute, atlas build,
    total frontend processing
  - Console output tagged [Sightmap Timing] / [Sightmap Sweep]

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* TEMP: strip grid data from sightmap response for timing debug

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): cache frame-invariant data in batch mode

Pre-compute and cache across all frames:
- Coarse grid lat/lng (coordinate transform done once, not per-frame)
- Bilinear interpolation weights (indices + weight arrays)
- Ellipsoid geometry for az/el (cell positions, normals, north/east vectors)
- Grid convergence angle for azimuthal projections

Per-frame now only computes:
- Source direction vector subtraction on ~119 coarse points
- 3x bilinear weight-apply (fast matmul, no index recomputation)
- sin/cos for direction on full grid

Expected speedup: source_grid from ~116ms/frame to ~15-25ms/frame
(~5-8x faster for 145 frames: ~17s → ~2-4s)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): eliminate full-grid trig via angle addition formula

Instead of: bilinear interp sin/cos → arctan2 → +convergence → radians → sin/cos
Now:        bilinear interp sin/cos → multiply by cached sin/cos(convergence)

Removes 6 numpy operations on the full 240K-cell grid per frame
(arctan2, where, add, radians, sin, cos) and replaces them with
4 multiply + 2 add operations (cheaper element-wise math).

Also computes coarse az as normalized sin/cos components directly
from the dot products, skipping degrees/radians conversions on
the coarse grid too.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add resolution dropdown + restore grid data

Add viewport-relative resolution control (1×, 0.5×, 0.25×, 0.125×)
to the Display section. Default is 0.25× (medium). The scale factor
multiplies the viewport's longest pixel dimension to produce
maxOutputDim sent to the backend.

- 1× = native (no decimation beyond viewport crop)
- 0.5× = half viewport dims
- 0.25× = quarter (default, balanced speed/quality)
- 0.125× = eighth (fastest, coarsest)

Server-side clamp raised from 800 to 4096 to support 1× on large
viewports.

Also restores grid data in sightmap responses (reverts the TEMP
debug strip).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightmap): scale frame limit by resolution

1× (maxDim≥800): 256 frames
0.5× (maxDim≥400): 512 frames
0.25× (maxDim≥200): 1024 frames
0.125× (maxDim<200): 2048 frames

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightmap): replace times array with startTime/endTime/stepSeconds

Send 3 scalar values instead of potentially 2000+ timestamp strings.
Backend generates timestamps internally from the range.

- Frontend sends ISO timestamps (e.g. '2026-06-11T19:53:00Z') and
  stepSeconds (e.g. 60)
- Node.js route validates the range and computes frame count for
  limit checking
- Python parses ISO start/end, generates time strings with timedelta
- Frame limit raised to 2048 max (at 0.125× resolution)
- Shrinks request payload from ~100KB to ~500B for large sweeps

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): range circles, max distance infinity toggle, viewport padding

- Draw dashed min/max distance circles on map when values are non-zero
  (orange for min, blue for max). Circles update on value change and
  center on sweep observer position when available.

- Add ∞ toggle button on Max Dist row. When active, sends maxDistance=-1
  to backend which skips viewport cropping and reads the full DEM (at
  the appropriate overview level for the resolution setting).

- Add Tooltip on Max Dist label explaining viewport-only vs full DEM
  behavior.

- Backend: when maxDistance > 0, pad viewport bounds by that distance
  in all directions so shadow-casting terrain beyond the visible extent
  is included. When maxDistance=-1 (infinity), viewport_bounds is set
  to None so the full DEM is read.

- Store: add maxDistInfinity per-element field (default false).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: range circles use projected polygon, infinity mode preserves viewport resolution

Range circles:
- Replace L.circle (broken on custom planetary CRS) with L.polygon
  built from 64 vertices computed in projected coordinates via
  crs.project/unproject. Works correctly on polar stereo and any
  custom L.Proj.CRS.

Infinity mode resolution fix:
- Separate DEM read extent from output grid extent. When infinity
  mode is active (maxDistance=-1), the full DEM is loaded at overview
  resolution for shadow tracing, but the output grid is sized to the
  viewport subset only — preserving the same cell density as normal
  mode.
- When maxDistance > 0 (finite padding), DEM read bounds are padded
  by maxDistance in all directions. Output grid still viewport-only.
- Both compute_sightmap and compute_sightmap_batch support
  output_offset_row/col parameters that offset output grid cells
  within the loaded DEM. Coarse subgrid, precompute, and batch
  context all respect the offset so rays trace through the full
  loaded DEM while only evaluating viewport cells.
- Add _compute_bounds_from_proj() to compute geographic bounds from
  projected viewport coords for correct response bounds.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: replace min/max distance fields with DEM Extent dropdown

Remove minDistance, maxDistance, maxDistInfinity fields and range
circles. Replace with a simple 'DEM Extent' dropdown (Viewport / Full
DEM) in the Display section, defaulting to Viewport.

- Viewport: uses only the DEM visible on screen (fast)
- Full DEM: reads the entire raster for distant shadow casting (slower,
  sends maxDistance=-1 to backend)

Removes ~120 lines of range circle rendering code that is no longer
needed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "refactor: replace min/max distance fields with DEM Extent dropdown"

This reverts commit b3d6cc503a170a522f48bdd7f5b21d723b9612e7.

* Revert "fix: range circles use projected polygon, infinity mode preserves viewport resolution"

This reverts commit 6b265aa823e9634e13d5857119826b174360d384.

* Revert "feat(sightline): range circles, max distance infinity toggle, viewport padding"

This reverts commit fd0c5c2262d9c2bccffa69102709be759333c1ad.

* feat(sightline): add Shadow Reach LOD field for distant shadow casting

Add a 'Shadow Reach' input (km) to the Display section that extends
terrain loaded for shadow computation beyond the visible viewport.
When set, the backend reads the viewport DEM at full resolution and
a padded border region at a coarser COG overview level, composites
them into a single array, and marches rays through the full composite
while outputting only the viewport portion.

- New open_dem_composite() reads viewport + low-res border
- Output grid offset support in _precompute_grid_arrays,
  _compute_sun_grid, _precompute_batch_grid_context
- Replaces minDistance/maxDistance with single shadowReach parameter
- Tooltip explains the viewport-extension concept
- Default 0 = viewport only (no performance impact)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): prevent OOM on large shadow reach, clamp to curvature, blur-only regen

- Redesigned open_dem_composite(): single DEM read of the padded
  region at a managed resolution (scales working_dim by viewport/pad
  ratio, capped at 4× max_working_dim) instead of building a massive
  array at viewport pixel scale then upsampling.
- Shadow reach clamped to planetary curvature limit: sqrt(2*R*h_max)
  where h_max=10km. For Moon (R=1737km) this caps at ~186km.
- Shadow Reach input only triggers sightmap regen on blur (unfocus),
  not on every keystroke.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): Enter key triggers regen on Shadow Reach input

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): respect drag order for sightmap z-index, fix drop indicator position

- After rendering a sightmap overlay, re-apply z-order based on the
  current element order in the store so the panel ordering is respected
  regardless of which sightmap finishes loading first.
- Fix drop indicator: changed border-bottom to border-top so the line
  appears above the target element (matching where the drop will place
  the dragged item).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): position-aware drag-and-drop indicator (above/below)

The drop indicator now shows border-top when hovering the upper half
of a target element (insert above) and border-bottom when hovering
the lower half (insert below). The drop logic uses the cursor's Y
position relative to the element midpoint to determine placement.

Applied to both SightlinePanel element cards and SweepSection cards.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): visibility timeline uses sightmap grid pixels, not horizon profile

- Visibility chart now checks the observer's pixel in each frame's
  sightmap grid (lit=1/2 → visible, shadow=0 → occluded) instead of
  comparing source az/el against the horizon profile.
- Observer pixel computed from projected coordinates (using CRS
  projection) when available, falling back to geographic bounds.
- Replaces horizon profile interpolation in drawVisibilityTimeline
  with direct grid pixel lookup via centerVisible.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore(sightline): remove timing logs and fix graph slider performance

- Remove all timing instrumentation from sightmap.py (timing dict,
  perf_counter calls, stderr serialization log, import time)
- Remove _timing from sightmap API responses (single + batch)
- Remove all console.log timing calls from SightlineTool.js
- Fix graph time slider performance: _scrubToFrame was redrawing
  horizon + visibility canvases synchronously AND triggering the
  same redraws again via the scrub callback (sweepShowAllFrames →
  updatePlaybackFrame → requestAnimationFrame). Now _scrubToFrame
  just sets the index and calls the callback, letting
  updatePlaybackFrame handle all redraws in a single rAF.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add distance-based fog shading to horizon profile

Backend (HorizonProfile.py):
- Track distance (meters) to the horizon point at each azimuth
- Output now includes [az, el, dist_m] per sample

Frontend (_drawHorizonCanvas):
- Parse distance from profile data (backward-compatible if missing)
- Replace uniform terrain fill with per-strip vertical fills
- Each strip's opacity mapped via log scale from distance:
  close horizon = opaque, far horizon = transparent
- Falls back to uniform fill if no distance data available

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase horizon profile fog opacity variation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): horizon profile now refreshes after new sweep completes

updatePlaybackFrame was using _horizonCache directly without validating
it against the current sweep center. After a new sweep set a different
sweepCenter, the stale cached profile was still drawn. Now routes through
fetchAndDrawHorizon which validates cache keys (lat/lng/height) and
refetches when the center has changed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add hover tooltip to horizon profile showing azimuth, elevation, and distance

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase horizon profile max radius from 5km to 50km

5km was too short to reach far crater rims on coarser DEMs, causing
the horizon profile to report deeply negative elevation angles where
the ray never found the actual skyline.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase horizon profile max radius to 250km

Also raised the backend cap from 100km to 500km to accommodate.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightline): add log stepping + early termination to horizon profile

Ray march now uses logarithmic step size (1px near, ~11px at 2500px)
so distant terrain is sampled more coarsely. Early termination stops a
ray once the maximum plausible terrain peak (10km, minus curvature)
at the current distance can't beat the already-found max elevation
angle. Together these reduce samples from ~2500/ray to ~200-600/ray
for 250km radius at 100m/pixel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add header with dual-handle log-scale horizon distance slider

Adds a 'Sightline Graphs' title and a dual-handle range slider to the
bottom panel header. The slider controls min/max horizon lookup distance
on a log scale (1m–250km). Dragging either handle invalidates the cache
and refetches the horizon profile with the new range. Default: 100m–250km.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): default horizon range to 1m–250km and add tippy tooltip

Changed min distance default from 100m to 1m. Added tippy tooltip on
the 'Horizon:' label explaining the dual-handle slider controls.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase crosshair circle and center dot radius by 1px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): deduplicate visibility chart time ticks and add user-select:none

When numTicks > frame count, multiple tick positions could round to the
same frame index, producing duplicate labels (e.g. two 'Jan 14 12:00').
Now skips any tick whose frameIdx matches the previous one. Also added
user-select: none to the time labels row.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): align visibility ticks with red slider position

- Cap numTicks to frame count (no more ticks than frames)
- Position ticks using frameIdx/(frameCount-1) — same formula as
  the red time slider — so they always line up exactly

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): set vstTimeStep width to 76px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): extract Sightline into dedicated backend module

Move sightmap and horizon profile endpoints from API/Backend/Utils/ into
a new API/Backend/Sightline/ module with its own setup.js, routes, and
scripts directory.

- POST /api/utils/sightmap → POST /api/sightline/sightmap
- POST /api/utils/gethorizonprofile → POST /api/sightline/horizonprofile
- private/api/sightmap.py → API/Backend/Sightline/scripts/sightmap.py
- private/api/HorizonProfile.py → API/Backend/Sightline/scripts/HorizonProfile.py
- Extract validateMissionsPath into shared API/validateMissionsPath.js
- Update frontend calls.js and E2E tests to use new paths

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): update SPICE kernel relative path after script relocation

The script moved from private/api/ to API/Backend/Sightline/scripts/,
so the relative path to spice/kernels/ needs 4 parent traversals
instead of 2.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs(sightline): update help with detailed algorithm descriptions

- Document sightmap viewshed algorithm: source position via SPICE,
  DEM composite, tangent-plane projection, ray-march viewshed, output grid
- Document horizon profile algorithm: per-azimuth ray march, elevation
  angle tracking, curvature correction, distance recording
- Add parameter tables for both endpoints
- Document performance methods: log stepping, early termination,
  managed resolution, batch streaming, frame limits
- Update SPICE paths to reflect new spice/ directory
- Update visibility timeline description (now pixel-based)
- Add fog shading, hover tooltip, and range slider to horizon profile docs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): extract modules for Indicators, Export, Horizon, Visibility

- Extract SightlineTool_Indicators.js (~689 lines): Az/El canvas gauges, sky dome, mini RAE
- Extract SightlineTool_Export.js (~488 lines): PNG, GIF, CSV, Grid export functions
- Extract SightlineTool_Horizon.js (~574 lines): horizon profile canvas, fog shading, tooltip
- Extract SightlineTool_Visibility.js (~278 lines): visibility timeline bar chart
- Extract RangeSlider reusable component to src/design-system/components/RangeSlider/
- SightlineTool.js reduced from 3082 to 1919 lines (thin delegates to new modules)
- SightlineTool_Graphs.js reduced from 1532 to 981 lines (delegates to Horizon/Visibility)
- Fix shadowReach isFinite validation bug (Devin Review feedback)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): invert color ramp to black→white, add horizon polygon overlay

- Invert WhiteBlack color ramp to BlackWhite (black→white gradient)
- Add faint horizon polygon on 2D map showing the horizon profile outline
  - Updates whenever horizon profile is fetched/redrawn
  - Removed when sightline graphs panel is closed
  - Very faint styling: white outline 0.35 opacity, fill 0.06 opacity

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add horizon polygon toggle checkbox, increase polygon opacity

- Add 'Polygon:' checkbox in graphs header bar (right of horizon range slider)
- Default off; toggling on shows the horizon polygon overlay on the 2D map
- Polygon is more visible: outline 0.6 opacity, fill 0.12 opacity, weight 1.5
- Checkbox state resets to off when graph panel closes
- Uses existing mmgis-checkbox component pattern

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): thicker polygon border (weight 3), add tippy on Polygon label

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): composite hover uses bounds-based lookup instead of tile projection

The _onCompositeHover function was using Globe_.litho.projection tile
coordinates (topLeftTile, tileResolution) which don't exist in the
sightmap data model. Replaced with bounds-based row/col computation
using data._bounds [west, south, east, north] which is what the
sightmap API actually returns.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): update azimuth lines on map pan/zoom

Azimuth SVG overlay uses pixel coordinates from latLngToContainerPoint.
When the map pans, these coordinates become stale but the overlay wasn't
being redrawn. Now listens for 'move' events on the map while the graph
panel is open and redraws the source azimuth lines on every move.
Listener is removed on panel close.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): composite hover uses projBounds for projected CRS maps

When the map uses a custom projected CRS, Leaflet e.latlng coordinates
are in projected meters, not geographic degrees. The hover function now
uses data._projBounds (projected) when in a projected CRS and
data._bounds (geographic) when in standard geographic CRS.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): update polygon tippy text, add user-select:none to labels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): project mouse coords before comparing to projBounds

Leaflet's e.latlng gives geographic lat/lng even in projected CRS maps.
For projected CRS (lunar south pole stereographic), we need to convert
these to projected coordinates via crs.project() before comparing
against data._projBounds. This was causing the hover to always produce
out-of-range row/col on projected CRS maps.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightline): progressive log2 stepping in sightmap ray march

Replace fixed-step march with distance-based progressive stepping:
step = base * max(1, log2(r+1)), combined with margin-based acceleration.

Near the observer every pixel is sampled. At r=1000px the step grows
to ~10x base; at r=25000px to ~15x base. This dramatically reduces
iterations for high-resolution DEMs (e.g. 10m USGS) where rays can
span 25,000+ pixels. Expected ~4-8x fewer samples per ray at distance
with negligible accuracy loss (distant terrain must be very tall to
affect the elevation angle).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "perf(sightline): progressive log2 stepping in sightmap ray march"

This reverts commit 97d29eecbbf9c8e7ede869fa5e8bfa4745871ed0.

* Revert "Revert "perf(sightline): progressive log2 stepping in sightmap ray march""

This reverts commit 975c772101aa0ebb4d161b82156100d77596e348.

* perf(sightline): add in-march early termination + reduce working DEM size

Two optimizations on top of the progressive log2 stepping:

1. In-march early termination (#4): at each sample, checks if the
   best-case terrain angle (MAX_TERRAIN_H minus curvature drop) at the
   current distance is below the source elevation. If so, no further
   terrain can block the source and the ray stops immediately. Most
   effective for high-elevation sources.

2. Match working DEM to output resolution (#5): reduced working_dim
   from 2x output to 1x output. Rays march through a ~400px array
   instead of ~800px, halving samples per ray and DEM I/O. Shadow
   accuracy is preserved since the output grid resolution is unchanged.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add 2-minute timeout to sightmap.py

Uses signal…
…aming, relative paths (#1002)

* fix: five bugs — az offset, playback opacity, progress indicator, mode switch, hover crash

1. sightmap.py: _compute_sun_grid used dem_rows instead of dem_cols for
   column pixel clamping → coarse subgrid positions were wrong → az/el
   off by ~30-45° on non-square DEMs

2. Playback/sweep overlay now inherits el.opacity as initial value
   instead of hardcoded 1.0. Default sweep opacity changed to null;
   applySweepOpacity falls back to el.opacity when ed.opacity unset

3. ProgressButton: added indeterminate mode (sliding bar animation)
   that activates automatically when loading=true and progress<=0.
   Static sightmap generation shows indeterminate; sweep shows %

4. switchElementMode: switching back to static now re-renders the
   cached lastData/lastResultGrid or marks changed=true for auto-regen

5. _onCompositeHover: guard against missing topLeftTile (backend
   sightmap response doesn't include tile-based fields)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct sightmap ray direction for polar stereographic projections

_compute_directions had two bugs in the projected CRS branch:

1. Missing grid convergence rotation: geographic azimuth was used
   directly in pixel space without rotating by the convergence angle
   (longitude for polar stereographic). This caused ~30-45° offset
   at the observer's longitude.

2. Inverted dy sign: cos(az) was used for dy when gt[5]<0, but
   increasing pixel-y = decreasing northing, so the formula must
   negate cos(az) — matching the geographic branch convention.

Also fix _showAzimuthLine/_updateSourceAzimuthLines: the fallback
path (no sweepCenter or indicatorLastDragPoint) now computes
centerLatLng from the map container center so _localNorthAngle
always receives a valid position for the convergence rotation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* test: add extensive Playwright tests for sightmap API endpoint

Covers:
- Input validation (missing fields, non-finite numerics, NaN, Infinity)
- Path traversal protection on DEM path
- Single-timestamp sightmap computation (structure, grid values, az/el plausibility)
- Batch multi-timestamp computation (results array, az variation)
- Custom Az/El source (el=0 all shadow, el=90 all visible)
- Error handling (invalid SPICE target, nonexistent DEM)
- Consistency (determinism, different times produce different grids)
- Observer height offset comparison
- maxOutputDim clamping
- Projected bounds for polar stereographic CRS

All DEM-dependent tests gracefully skip when Lunar South Pole
mission is not available. AUTH=local mode returns HTML instead
of JSON — tests detect and skip.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): use observer position for azimuth indicator north offset

The azimuth indicator line was computing _localNorthAngle from the map
center (fallback when sweepCenter is unset), which drifts as the user
pans and may be at the south pole where longitude is ~0. The sightmap
shadows are computed from the actual observer position with correct
per-cell convergence, creating a mismatch.

Fix: store the observer lat/lng as sweepCenter in sweepElData when the
static sightmap completes (same as sweep mode already does). This
ensures _showAzimuthLine, _updateSourceAzimuthLines, the crosshair
placement, and the horizon profile all use the correct observer position
for the north angle calculation.

Also add indicatorLastDragPoint fallback to _updateSourceAzimuthLines
(was missing, unlike _showAzimuthLine which already had it).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightmap): correct azimuth CW/CCW convention in _sun_azel_batch

The east vector was computed as normal × north (= Up × N = -East = West),
causing atan2(dot(sun, west), dot(sun, north)) to return counter-clockwise
azimuths. This mirrored the result: geographic 240° CW reported as 120°.

Fix: use north × normal (= N × Up = East) per right-hand rule, matching
SPICE's azccw=False (clockwise from north) convention.

Verified: batch az now matches SPICE exactly (226.2493° vs 226.2493°).
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): use fixed-width fade gradient for visibility segments

The gradient on segment transitions was 30% of the segment width,
making wide segments have long fades and narrow segments short fades.
Now uses a fixed 2% of the total bar width for all transitions,
producing uniform fade-in and fade-out lengths regardless of segment size.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): wire resolution setting to maxOutputDim + gifshot fixes

Resolution dropdown (Low/Med/High/Ultra) now controls the sightmap
grid size sent to the backend:
  Static:  100 / 200 / 400 / 800 px
  Sweep:    50 / 100 / 200 / 400 px

Also:
- Add willReadFrequently to GIF export canvas contexts (suppresses
  Chrome warning about slow getImageData readback)
- Add gifshot progressCallback to update export progress during the
  GIF encoding phase (90→100%) instead of jumping at the end

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): remove dead SightlineTool_Manager and Layer specific DEMs config

SightlineTool_Manager.js was part of the old JS tile-fetching algorithm
(now replaced by the backend sightmap.py endpoint). Nothing imports it.

Remove:
- SightlineTool_Manager.js
- 'Layer specific DEMs' config section (variables.data with demtileurl,
  minZoom, maxNativeZoom, boundingBox) from config.json and all blueprint
  mission configs
- vars.data validation in initialize() — replaced with vars.dem check

The Viewshed tool's separate Manager and its layer-level demtileurl
references are unaffected.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): downsample large DEMs at read time via GDAL decimation

High-res DEMs (e.g. 100m vs 4000m) were read fully into memory even when
the output grid was small, causing:
1. Slow computation (full array I/O + march through many more pixels)
2. Windows pickle truncation on multiprocessing (huge arrays exceed
   pickle buffer limits when serialized to spawn'd worker processes)

Fix: open_dem() now accepts max_working_dim and uses GDAL's ReadAsArray
with buf_xsize/buf_ysize for server-side bilinear decimation. The
geotransform is adjusted to match the resampled pixel grid. Working dim
is set to max(max_output_dim * 4, 1000) — 4x oversampling for terrain
detail in the ray march, capped at native resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): GDAL overview bands, DEM caching, Numba warmup, reduce working_dim

Three optimizations to cut sightmap generation time on large DEMs:

1. GDAL overview bands: If the DEM has pre-computed overviews (COGs),
   read from the overview band directly instead of decimating the full
   raster in memory. Falls back to ReadAsArray(buf_xsize/ysize) for
   DEMs without overviews.

2. DEM caching: Module-level cache keyed by (path, working_dim) avoids
   re-reading the same DEM within a batch run. LRU eviction at 4 entries.

3. Numba JIT warmup: Trigger compilation at module import with a tiny
   2x2 dummy array so the ~5-10s first-compilation cost is paid during
   startup, not during the actual sightmap computation.

4. Reduce working_dim from 4x to 2x output grid (min 500px instead of
   1000px). Halves the march distance and array sizes while maintaining
   sufficient terrain detail for shadow boundary accuracy.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: add timing instrumentation to sightmap.py

Temporary timing logs to stderr at each phase:
- imports, numba_warmup, load_kernels, spice_azel, unload_kernels
- open_dem (with working_dim and actual array size)
- _precompute_grid, _compute_sun_grid, _compute_directions
- _numba_march, total

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: pipe sightmap stderr to Node console for timing visibility

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: include timing data in sightmap JSON response body

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): disk-based .npy cache for decimated DEMs

The timing data showed open_dem takes 12.8s (GDAL reading/decimating a
large DEM) while the actual Numba march takes only 0.065s.  Since each
sightmap call spawns a new Python process, the in-memory cache is lost.

Fix: after the first GDAL decimate, save the resulting numpy array and
geotransform metadata to .npy/.json files next to the source DEM.
Subsequent process invocations load the pre-decimated array via np.load
(~0.01s).  Cache is invalidated if the source DEM file is newer.

Expected improvement: first run ~14s (unchanged), second+ runs ~2s.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): require COG format for large DEMs, remove npy cache

Replace the disk-based .npy cache with a simpler approach: require the
DEM to be a Cloud Optimized GeoTIFF (COG) when decimation is needed.
COGs have internal tiled layout + overview pyramids so GDAL can read at
any target resolution by seeking to the right bytes — no full-file scan.

If the DEM is not a COG and is large enough to need decimation, throw a
clear error with the gdal_translate command to convert it.

Small DEMs that fit within max_working_dim are read directly regardless
of format (no performance issue at small sizes).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: pass through Python error messages to frontend on sightmap failure

When sightmap.py exits with code!=0, the Node handler was returning a
generic 'sightmap computation failed' message. Now it tries to parse
the structured JSON error from stdout first, so the actual error
(e.g. 'DEM is not a COG') reaches the frontend.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sanitize COG error message — no paths or tracebacks in response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove traceback from JSON error response, log to stderr only

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: show actual sightmap error message in toast

- calls.api error callback now parses JSON response body from jqXHR
- SightlineTool error handlers display server error message (e.g. COG)
- No traceback or paths in error responses (security)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove duplicate 'sightmap error' prefix from Python error messages

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): temporarily disable multiprocessing for batch mode

Run all timestamps sequentially in the main process so the DEM stays
in memory and avoids pickle serialization overhead per worker.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightmap): remove multiprocessing and Resolution UI option

- Remove all multiprocessing infrastructure (pool, workers, shared state)
- Batch timestamps run sequentially in the main process
- Remove Resolution dropdown from UI, default to ultra (800px static, 400px sweep)
- Always auto-regenerate on map move (no resolution-gated cutoff)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: temporarily disable Numba JIT for benchmarking comparison

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: re-enable Numba JIT after benchmarking (saves ~6s per frame)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: re-add timing instrumentation to sightmap response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: compute grid convergence analytically, skip GDAL TransformPoints

_compute_directions was calling _pixels_to_geo_batch on all 940K cells
(969x969 grid) to get each cell's longitude for the convergence angle.
This took ~1s (29% of total).

Now computes convergence directly from projected coordinates using
atan2(x - false_easting, sign*(y - false_northing)) — pure numpy
math, no GDAL coordinate transform. Expected: ~0.01s.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: skip kernel unloading + increase coarse subgrid step to 50

- Remove spiceypy.unload() calls — process exits immediately after
  response so OS cleanup is sufficient. Saves ~0.235s.
- Increase COARSE_AZEL_STEP from 10 to 50 — reduces coarse subgrid
  points from ~9400 to ~400 for sun az/el interpolation. Sun position
  varies slowly across the DEM so fewer sample points suffice.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap.py

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: interpolate sun azimuth in sin/cos space to prevent wrap artifacts

When coarse subgrid has azimuth values near the 360°/0° boundary
(e.g. 355° and 5°), linear interpolation produces ~180° — completely
wrong direction that flips shadows. Now interpolates sin(az) and
cos(az) separately, then recovers the angle via atan2. This handles
the circular wrap correctly.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: viewport-aware sightmap — clip DEM read to visible area at native resolution

Frontend sends current map viewport bounds (projected coords) with each
static sightmap request. Backend clips the DEM read to the viewport
intersection, reading at native resolution (capped at maxOutputDim).

When zoomed in, this means the sightmap covers just the visible area
but at much higher resolution than the full-DEM downsampled version.
When zoomed out, viewport encompasses the full DEM and behavior is
unchanged.

For a 30993x30993 DEM zoomed to 1/10th coverage, instead of reading
the full raster decimated to 1600x1600, we read only ~3000x3000 native
pixels for the visible window — higher res and faster.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: compute viewport projected bounds from container corners, not lat/lng bbox

In polar stereographic CRS, the lat/lng bounding box from
getBounds() maps to a distorted region in projected space.
Now samples all 4 container pixel corners, projects each through
the CRS, and takes the envelope for correct projected bounds.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: position sightmap overlay using projected NW/SE corners directly

In polar/rotated CRS, L.latLngBounds normalises by min/max lat/lng,
which shuffles corners — getNorthWest() and getSouthEast() return
points that don't correspond to the projected rectangle's NW and SE.
The overlay is then mispositioned and stretched.

New _projImageOverlay() helper overrides the overlay's _reset method
to compute pixel position from the projected NW (xmin, ymax) and
SE (xmax, ymin) corners via latLngToLayerPoint, bypassing the
normalisation entirely.  Applied to all three overlay creation paths:
static sightmap, heatmap, and sweep frame.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: override _animateZoom on projected overlay to prevent zoom jump

The default _animateZoom reads the normalised L.latLngBounds which
has wrong corners in polar CRS, causing the overlay to briefly jump
to the top-left during zoom transitions.  Now _animateZoom uses the
projected NW corner via _latLngToNewLayerPoint for correct animation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: wire viewport bounds through batch/sweep mode

Frontend sweep call now sends viewportBounds.  Backend
compute_sightmap_batch accepts and passes viewport_bounds
to open_dem so playback also clips to the visible DEM region
at native resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: equalize sweep/static resolution + show 'Sweeping' on progress button

- _resolutionToMaxDim now returns 800 for both modes (was 400 for sweep)
- ProgressButton label shows children text alongside percentage
- SightlineElement shows 'Sweeping'/'Generating' while loading

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: actually commit _resolutionToMaxDim change to equalize sweep/static res

Was missed from the previous commit — sweep was still getting 400 instead of 800.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: re-fetch horizon profile after pan so charts stay in sync

When the user panned, invalidateHorizonCache set _horizonCache to null
but never triggered a re-fetch.  Subsequent scrub or playback frame
changes found the cache empty and either skipped the horizon redraw or
drew the visibility timeline with no profile (marking everything not
visible).

- _onPanEnd now calls invalidateAndRefetch() which re-fetches the
  horizon profile if the graph panel is open.
- _scrubToFrame and updatePlaybackFrame fall back to fetchAndDrawHorizon
  when the cache is null instead of drawing with missing data.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile azimuth for projected CRS (polar stereo)

HorizonProfile.py was marching along pixel/raster-space directions,
treating pixel-up as north.  In polar stereographic the grid north
axis is rotated from true north by the grid convergence angle, so the
profile azimuths were offset and the terrain silhouette appeared
rotated in the chart.

Added _grid_convergence() which computes the convergence at the
observer's projected coordinates via atan2(x - FE, -(y - FN)).  Each
geographic azimuth is now rotated by the convergence before marching
in pixel space, making the profile azimuths true geographic azimuths.
No change for geographic (unprojected) CRS.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile convergence formula for polar stereo

Two bugs in the previous convergence fix:

1. _grid_convergence used atan2(x, -y) unconditionally, but for
   south-pole stereo the sign should be +1 (north is away from
   pole = positive y). Now uses north_sign = -1 for north-pole,
   +1 for south-pole, matching sightmap.py exactly.

2. The convergence was subtracted (geo_az - convergence) when it
   should be added (geo_az + convergence), matching sightmap.py's
   convention where convergence rotates from grid north to true
   north.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: extract rate limiters into shared scripts/rateLimiters.js module

- Create scripts/rateLimiters.js exporting apilimiter, authLimiter, computeLimiter
- Remove inline rateLimit() definitions from scripts/server.js
- Remove authLimiter/computeLimiter from the 's' setup object
- Update Users/routes/users.js: import authLimiter directly, replace late-binding wrapper
- Update Users/setup.js: remove router._authLimiter assignment
- Update Utils/routes/utils.js: import computeLimiter directly, replace all 8 late-binding wrappers
- Update Utils/setup.js: remove router._computeLimiter assignment
- Update unit tests to import from the shared module

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.26-20260609 [version bump]

* fix: use geodesic destination for azimuth lines instead of angle rotation

Replace the _localNorthAngle + screen-space rotation approach with a
direct forward geodesic method: compute a destination lat/lng 1° along
the desired azimuth, project it through Leaflet, and draw the line.

This avoids potential compound angle errors and works correctly for
any CRS because it uses Leaflet's own projection pipeline end-to-end
rather than computing a screen-space north-offset angle and rotating.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: convert reference mission DEMs to Cloud Optimized GeoTIFF (COG)

Both the Earth (USGS SF Hill) and Lunar South Pole (LRO LOLA 4000m)
DEMs are now tiled COGs with deflate compression and overviews.
This ensures consistent behavior with the sightmap COG requirement
and enables fast overview-based reads at any resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add lunar LSMT to chronice, fix TimeUI indicator cleanup and observer time sync

- chronice.py: add lunar LSMT support using SPICE et2lst with observer
  longitude; format: LDAY-NNNNNLHH:MM:SS; reverse conversion via iterative
  refinement
- utils.js: pass optional lng param through to chronice.py
- SightlineTool.js: remove TimeUI indicator on mode switch, cancel sweep,
  resweep start, and pan-end; pass lng from observer point for LSMT observers
- SightlineElement.jsx: update global TimeControl when observer time inputs
  are changed (blur/Enter), fixing Mars SOL time not updating the TimeUI
- Lunar ref mission config: add Moon (LSMT) observer with type=lsmt

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: LSMT lng fallback to map center, hide empty DEM dropdown, Enter key on observer time

- _getObserverLng: fall back to map center when indicatorLastDragPoint is null
- SightlineElement: hide DEM dropdown when no data options configured
- SightlineElement: add onKeyDown Enter handler on observer time inputs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Improve Mars Reference Mission

* chore: bump version to 5.0.28-20260610 [version bump]

* fix: sightmap overlay CRS mismatch for non-custom projections, restore config descriptions

- Only use _projImageOverlay and viewport clipping when the mission uses
  a custom projected CRS (projection.custom=true). For standard longlat/
  Mercator missions (like Mars), the DEM's projected bounds are in a
  different CRS than the map, causing misplaced overlays.
- Restore Layer-specific DEMs config row and improve field descriptions
  in sightline tool config.json (lost during ShadeTool→SightlineTool rename).
- Add sweepColorRamps and observer type examples to descriptionFull.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: restore sightline config descriptions from ShadeTool, remove data row, clear default name

- Remove Layer-specific DEMs config row (previously asked to remove)
- Restore detailed descriptions from old ShadeTool config:
  - Sources: documents name/value properties, dropdown usage, kernel path
  - Observers: documents name/value/frame/body, chronos setup path
  - Default Height: full description of height parameter behavior
  - Observer Time Placeholder: documents format string usage
  - Frame/Body fields: proper SPICE reference descriptions
- Remove 'Sightline N' default element name (now empty)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: observer time input 7hr drift — chronice result parsed as local instead of UTC

Root cause: chronice lmst→utc returns '2026-05-30T21:36:57.975' (no Z suffix).
The old ShadeTool correctly did: result.replace(' ', 'T') + 'Z'
The new code used a regex chain that failed when milliseconds were present
without a trailing Z, leaving the string timezone-ambiguous. new Date()
then parsed it as local time (UTC-7), adding ~7 hours.

Fix: strip milliseconds then unconditionally append Z, matching the old
ShadeTool approach. The /ZZ$/ → Z guard prevents double-Z if chronice
ever returns a Z-suffixed result in the future.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sightmap sun direction for cylindrical projections, 1s time drift, tab-switch regen, add editable time field

1. Sightmap sun direction: _compute_directions now only applies convergence
   rotation for azimuthal projections (stereo/gnomonic). For cylindrical
   projections (Equidistant Cylindrical, Mercator), grid north = geographic
   north so convergence = 0. Previously applied polar-stereo formula to all
   projected CRS, giving ~90deg rotation on Mars DEM.

2. Observer time 1-second drift: restored _lastConvertedMs pattern from old
   ShadeTool. Saves sub-second precision from observer->UTC conversion and
   re-attaches it in UTC->observer reverse conversion for exact round-trips.

3. Tab-switch regeneration: _onTimeChange now tracks _lastGeneratedTime and
   skips if unchanged, preventing redundant sightmap computation when
   TimeControl re-broadcasts the same time on tab refocus.

4. Editable time field (vstOptionTime): restored from old ShadeTool. Shows
   current end time in configured format (DOY, etc), editable on blur/Enter.
   Parses via utcTimeFormat if configured, else appends Z directly.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: match old ShadeTool vstOptionTime styling and DOY format

- CSS matches old ShadeTool exactly: full-width centered input, bold 14px,
  color-p0 bg, color-a1-5 text, transparent border that shows color-c on focus
- Clock icon positioned absolute right (pointer-events: none) as in original
- Structure uses flexbetween wrapper matching old jQuery markup
- Mars reference mission utcTimeFormat changed to DOY: '%Y-%j %H:%M:%S'
  giving output like '2026-150 21:36:57' instead of ISO format

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* ui: hide observer start time in static mode, show only 'Time' field

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: horizon profile rotation for cylindrical CRS + visibility chart text non-selectable

1. HorizonProfile.py _grid_convergence: same fix as sightmap.py — only
   apply convergence for azimuthal projections (stereo/gnomonic). For
   cylindrical projections (Mars Equidist. Cylindrical), convergence = 0,
   so horizon terrain profile azimuths are now correct.

2. Visibility chart (.sightlineVisWrap): added user-select: none so
   dragging the timeline scrubber doesn't accidentally highlight text.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: horizon profile aspect ratio for geographic CRS + crosshair styling/lag

- HorizonProfile.py: compute per-axis pixel scales (px_scale_x, px_scale_y)
  so the march direction accounts for longitude compression at observer
  latitude. For geographic CRS at 38°N, 1° lon ≈ 0.79 × 1° lat in meters;
  without this the march traces wrong physical angles, distorting azimuths.
  Also computes correct per-step physical distance instead of using the
  averaged pixel_scale.

- Crosshair restyled: smaller (8px circle, 5px arms), lime green with
  black borders (box-shadow outline).

- Crosshair converted from raw DOM element to Leaflet DivIcon marker.
  Leaflet handles positioning in its own transform pipeline, eliminating
  the lag that occurred when updating CSS left/top on the move event.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add lime center dot at visible map center while sightline tool is open

Small 6px lime green dot with 1px black border, always at 50%/50% of
the map container (CSS-only positioning, no event tracking needed).
Added on make(), removed on destroy().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: update crosshair position immediately when sweepCenter is set

Previously the crosshair only corrected its position on the next pan
event. Now _updateCrosshairPosition() is called right after sweepCenter
is stored for both static sightmap and batch/sweep completion.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sightmap route security + batch limits + test fix (Devin Review)

- Add SAFE_NAME_RE validation on target, obsRefFrame, obsBody to prevent
  directory traversal via SPICE kernel paths (matches /ll2aerll_bulk).
- Add MAX_TIMES=200 cap on sightmap batch to prevent resource exhaustion.
- Fix E2E test: batch response is a raw JSON array, not { results: [...] }.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: add error handler + headersSent guards on sightmap spawn

Match ll2aerll_bulk pattern: handle child.on('error') and
child.stdin.on('error') to prevent hung responses if Python
fails to start. Add !res.headersSent guards on all response
paths in the close handler.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: get_pixel_scale uses actual array rows instead of ds.RasterYSize

After open_dem decimates a large DEM, gt[5] is scaled but ds still has
the original RasterYSize. Using ds.RasterYSize with the decimated gt
produces a wrong mid_lat for geographic CRS pixel scale. Now accepts
dem_rows directly from dem.shape.

Also: encodeURIComponent the chronice lng argument to match the other
CLI args (consistency with unquote() on the Python side).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct East vector cross product order in sun_azel_at_cell

cross(normal, north) yields West, not East. Changed to
cross(north, normal) to match the batch version _sun_azel_batch.
Currently unused at runtime but prevents future bugs.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove dead code from sightmap.py

Removed unused scalar functions that were superseded by vectorized
equivalents: sun_azel_at_cell (replaced by _sun_azel_batch),
is_nodata (replaced by _vectorized_is_nodata), geo_to_pixel (never
called). Also removed the unused ds parameter from open_dem return
value and _compute_bounds signature — ds was only kept alive for
get_pixel_scale which no longer needs it after the dem_rows fix.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove dead code from SightlineTool frontend

SightlineTool.js: removed showSightlinemapLayers, showSweepLayers,
refreshAllHeatmaps, _nextPow2 — all defined but never called.

SightlineTool_Algorithm.js: removed the entire old client-side
sightline algorithm (sightline, processUp/Down, mask, curveData,
isNoData, compositeResults, calcHeight*, initializeGrids, perOctant)
and their unused imports (jquery, F_, L_, G_). Only
cumulativeVisibility is called externally; all other methods were
from the pre-backend era and superseded by sightmap.py.

SightlineTool_Graphs.js: removed _localNorthAngle, replaced by
the geodesic _destinationPoint + _azimuthEndpoint approach.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: color picker/ramp dropdown clipping + reorder default colors

Remove overflow:hidden from vstSightlineItem, vstSweepCard, and
vstSweepCardsSection so absolutely-positioned color picker palettes
and color ramp dropdowns are no longer clipped by their parent
containers. Add border-radius to headers directly to preserve
rounded corners.

Bump vstColorPalette z-index from 100 to 10000 to match the
ColorRampPicker popup z-index.

Reorder MULTI_SOURCE_COLORS: yellow -> blue -> red -> green
(swapped blue and red positions).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: improve LSMT inverse conversion precision from ~30s to <1s

et2lst returns integer (hr, mn, sc) so one lunar second spans ~29 ET
seconds. The old iterative loop converged to ±1 lunar second, giving
~30s UTC precision. Now uses binary search after the coarse loop to
find the exact ET boundary where the second ticks over, narrowing to
<0.5 ET seconds. Result is placed at the midpoint of the lunar
second window for minimal round-trip error.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: color picker palette uses fixed positioning to escape overflow

The Collapsible panel has overflow:hidden for its open/close
animation, which clips the color picker dropdown. Changed the
palette to position:fixed, computed from the swatch's bounding
rect on click, so it escapes all overflow containers.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: revert fixed positioning, use overflow:visible on open panels

Reverts position:fixed approach. Instead overrides overflow to
visible on open Collapsible panels inside sightlineTool via
[data-open] selector, so the color palette can extend past the
panel boundary while keeping overflow:hidden during animations.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): implement 6 improvements to Sightline tool

1. Combine Playback and Composite Sweeps: always build both heatmap
   and atlas after sweep; mode switching is instantaneous without
   re-running the sweep.

2. Min/Max Distance options: add UI inputs and pass minDistance/
   maxDistance through to sightmap.py ray-march kernel and
   HorizonProfile.py. Includes curvature-based early termination.

3. Better Color Ramps + Fix Transparency Bug: expose sweepColorRamps
   in admin config, reorder defaults, fix evalColor/evalColorWithStops
   discrete bin index calculation (Math.floor -> Math.round).

4. Draggable Horizon Profile Point: crosshair marker is now interactive
   and draggable; on dragend updates indicatorLastDragPoint and refetches
   horizon profile at the new location.

5. Vis Chart - Remove Gradients: remove gradient transition logic from
   drawVisibilityTimeline; uncertain regions shown as occluded.

6. Document Algorithms: add detailed algorithm documentation to
   sightmap.py and HorizonProfile.py headers.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.1.1-20260611 [version bump]

* fix: resolve merge conflict in configure/package.json

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): set mode before sweepShowAllFrames, add distance to horizon cache key

- switchElementMode now sets sightlineMode='playback' before calling
  sweepShowAllFrames, which filters by sightlineMode.
- Horizon profile cache now includes maxDist/minDist so changing distance
  parameters invalidates the cache and triggers a re-fetch.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): address 4 review issues

1. Default color ramps: add inferno + viridis to front of defaults;
   remove sweepColorRamps from all reference mission configs so they
   use the built-in defaults.

2. Mode switching: keep Results section open when switching between
   composite/playback (don't collapse if sweep data exists); call
   sweepShowFrame directly for the element when switching to playback.

3. Crosshair dragging: use Leaflet.Editable (enableEdit/disableEdit)
   instead of L.marker draggable option; listen on 'editable:dragend'.
   Map click handler now only triggers static sightline when element
   is in static mode - prevents unwanted resweep in playback mode.

4. maxDistance fix: backend route (utils.js) was not passing
   minDistance/maxDistance through to the Python sightmap.py stdin
   payload. Added both fields to payloadObj.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): hardcode 6 color ramps, revert crosshair to non-draggable

1. Color ramps: remove custom/configurable sweepColorRamps entirely.
   Hardcode exactly 6 ramps:
   - [transparent, color]
   - [transparent, color, transparent]
   - Inferno
   - Viridis
   - Red → Green (RdYlGn)
   - Black → White (Greys)

2. Crosshair: revert to original non-draggable behavior (interactive: false).
   Remove indicatorLastDragPoint from store and all references.
   Horizon profile and visibility chart now always use the current map
   center — they auto-update on pan end via the existing
   invalidateAndRefetch() call in _onPanEnd.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): reverse B&W ramp to White→Black, single color stop

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): anchor horizon profile to sweep center when available

Revert the horizon profile to use sweepCenter when a sweep exists,
falling back to the current map center when no sweep has been run.
This keeps the horizon chart, entity arcs, and visibility timeline
all consistent with the sweep observer location.

Skip horizon invalidation on pan when anchored to sweep center
to avoid unnecessary backend calls.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightline): add timing instrumentation to sightmap pipeline

Python backend (sightmap.py):
  - Timing for: kernel loading, SPICE az/el, DEM open, grid precompute,
    per-frame sun grid + march + tolist, json.dumps, total
  - Batch response now returns {results, _timing} with per-frame arrays
  - DEM/output dimensions logged for context

Node.js route (utils.js):
  - Log total spawn-to-close time, JSON parse time, stdout size

Frontend (SightlineTool.js):
  - Log API round-trip, grid parsing, heatmap compute, atlas build,
    total frontend processing
  - Console output tagged [Sightmap Timing] / [Sightmap Sweep]

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* TEMP: strip grid data from sightmap response for timing debug

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): cache frame-invariant data in batch mode

Pre-compute and cache across all frames:
- Coarse grid lat/lng (coordinate transform done once, not per-frame)
- Bilinear interpolation weights (indices + weight arrays)
- Ellipsoid geometry for az/el (cell positions, normals, north/east vectors)
- Grid convergence angle for azimuthal projections

Per-frame now only computes:
- Source direction vector subtraction on ~119 coarse points
- 3x bilinear weight-apply (fast matmul, no index recomputation)
- sin/cos for direction on full grid

Expected speedup: source_grid from ~116ms/frame to ~15-25ms/frame
(~5-8x faster for 145 frames: ~17s → ~2-4s)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): eliminate full-grid trig via angle addition formula

Instead of: bilinear interp sin/cos → arctan2 → +convergence → radians → sin/cos
Now:        bilinear interp sin/cos → multiply by cached sin/cos(convergence)

Removes 6 numpy operations on the full 240K-cell grid per frame
(arctan2, where, add, radians, sin, cos) and replaces them with
4 multiply + 2 add operations (cheaper element-wise math).

Also computes coarse az as normalized sin/cos components directly
from the dot products, skipping degrees/radians conversions on
the coarse grid too.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add resolution dropdown + restore grid data

Add viewport-relative resolution control (1×, 0.5×, 0.25×, 0.125×)
to the Display section. Default is 0.25× (medium). The scale factor
multiplies the viewport's longest pixel dimension to produce
maxOutputDim sent to the backend.

- 1× = native (no decimation beyond viewport crop)
- 0.5× = half viewport dims
- 0.25× = quarter (default, balanced speed/quality)
- 0.125× = eighth (fastest, coarsest)

Server-side clamp raised from 800 to 4096 to support 1× on large
viewports.

Also restores grid data in sightmap responses (reverts the TEMP
debug strip).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightmap): scale frame limit by resolution

1× (maxDim≥800): 256 frames
0.5× (maxDim≥400): 512 frames
0.25× (maxDim≥200): 1024 frames
0.125× (maxDim<200): 2048 frames

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightmap): replace times array with startTime/endTime/stepSeconds

Send 3 scalar values instead of potentially 2000+ timestamp strings.
Backend generates timestamps internally from the range.

- Frontend sends ISO timestamps (e.g. '2026-06-11T19:53:00Z') and
  stepSeconds (e.g. 60)
- Node.js route validates the range and computes frame count for
  limit checking
- Python parses ISO start/end, generates time strings with timedelta
- Frame limit raised to 2048 max (at 0.125× resolution)
- Shrinks request payload from ~100KB to ~500B for large sweeps

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): range circles, max distance infinity toggle, viewport padding

- Draw dashed min/max distance circles on map when values are non-zero
  (orange for min, blue for max). Circles update on value change and
  center on sweep observer position when available.

- Add ∞ toggle button on Max Dist row. When active, sends maxDistance=-1
  to backend which skips viewport cropping and reads the full DEM (at
  the appropriate overview level for the resolution setting).

- Add Tooltip on Max Dist label explaining viewport-only vs full DEM
  behavior.

- Backend: when maxDistance > 0, pad viewport bounds by that distance
  in all directions so shadow-casting terrain beyond the visible extent
  is included. When maxDistance=-1 (infinity), viewport_bounds is set
  to None so the full DEM is read.

- Store: add maxDistInfinity per-element field (default false).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: range circles use projected polygon, infinity mode preserves viewport resolution

Range circles:
- Replace L.circle (broken on custom planetary CRS) with L.polygon
  built from 64 vertices computed in projected coordinates via
  crs.project/unproject. Works correctly on polar stereo and any
  custom L.Proj.CRS.

Infinity mode resolution fix:
- Separate DEM read extent from output grid extent. When infinity
  mode is active (maxDistance=-1), the full DEM is loaded at overview
  resolution for shadow tracing, but the output grid is sized to the
  viewport subset only — preserving the same cell density as normal
  mode.
- When maxDistance > 0 (finite padding), DEM read bounds are padded
  by maxDistance in all directions. Output grid still viewport-only.
- Both compute_sightmap and compute_sightmap_batch support
  output_offset_row/col parameters that offset output grid cells
  within the loaded DEM. Coarse subgrid, precompute, and batch
  context all respect the offset so rays trace through the full
  loaded DEM while only evaluating viewport cells.
- Add _compute_bounds_from_proj() to compute geographic bounds from
  projected viewport coords for correct response bounds.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: replace min/max distance fields with DEM Extent dropdown

Remove minDistance, maxDistance, maxDistInfinity fields and range
circles. Replace with a simple 'DEM Extent' dropdown (Viewport / Full
DEM) in the Display section, defaulting to Viewport.

- Viewport: uses only the DEM visible on screen (fast)
- Full DEM: reads the entire raster for distant shadow casting (slower,
  sends maxDistance=-1 to backend)

Removes ~120 lines of range circle rendering code that is no longer
needed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "refactor: replace min/max distance fields with DEM Extent dropdown"

This reverts commit b3d6cc503a170a522f48bdd7f5b21d723b9612e7.

* Revert "fix: range circles use projected polygon, infinity mode preserves viewport resolution"

This reverts commit 6b265aa823e9634e13d5857119826b174360d384.

* Revert "feat(sightline): range circles, max distance infinity toggle, viewport padding"

This reverts commit fd0c5c2262d9c2bccffa69102709be759333c1ad.

* feat(sightline): add Shadow Reach LOD field for distant shadow casting

Add a 'Shadow Reach' input (km) to the Display section that extends
terrain loaded for shadow computation beyond the visible viewport.
When set, the backend reads the viewport DEM at full resolution and
a padded border region at a coarser COG overview level, composites
them into a single array, and marches rays through the full composite
while outputting only the viewport portion.

- New open_dem_composite() reads viewport + low-res border
- Output grid offset support in _precompute_grid_arrays,
  _compute_sun_grid, _precompute_batch_grid_context
- Replaces minDistance/maxDistance with single shadowReach parameter
- Tooltip explains the viewport-extension concept
- Default 0 = viewport only (no performance impact)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): prevent OOM on large shadow reach, clamp to curvature, blur-only regen

- Redesigned open_dem_composite(): single DEM read of the padded
  region at a managed resolution (scales working_dim by viewport/pad
  ratio, capped at 4× max_working_dim) instead of building a massive
  array at viewport pixel scale then upsampling.
- Shadow reach clamped to planetary curvature limit: sqrt(2*R*h_max)
  where h_max=10km. For Moon (R=1737km) this caps at ~186km.
- Shadow Reach input only triggers sightmap regen on blur (unfocus),
  not on every keystroke.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): Enter key triggers regen on Shadow Reach input

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): respect drag order for sightmap z-index, fix drop indicator position

- After rendering a sightmap overlay, re-apply z-order based on the
  current element order in the store so the panel ordering is respected
  regardless of which sightmap finishes loading first.
- Fix drop indicator: changed border-bottom to border-top so the line
  appears above the target element (matching where the drop will place
  the dragged item).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): position-aware drag-and-drop indicator (above/below)

The drop indicator now shows border-top when hovering the upper half
of a target element (insert above) and border-bottom when hovering
the lower half (insert below). The drop logic uses the cursor's Y
position relative to the element midpoint to determine placement.

Applied to both SightlinePanel element cards and SweepSection cards.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): visibility timeline uses sightmap grid pixels, not horizon profile

- Visibility chart now checks the observer's pixel in each frame's
  sightmap grid (lit=1/2 → visible, shadow=0 → occluded) instead of
  comparing source az/el against the horizon profile.
- Observer pixel computed from projected coordinates (using CRS
  projection) when available, falling back to geographic bounds.
- Replaces horizon profile interpolation in drawVisibilityTimeline
  with direct grid pixel lookup via centerVisible.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore(sightline): remove timing logs and fix graph slider performance

- Remove all timing instrumentation from sightmap.py (timing dict,
  perf_counter calls, stderr serialization log, import time)
- Remove _timing from sightmap API responses (single + batch)
- Remove all console.log timing calls from SightlineTool.js
- Fix graph time slider performance: _scrubToFrame was redrawing
  horizon + visibility canvases synchronously AND triggering the
  same redraws again via the scrub callback (sweepShowAllFrames →
  updatePlaybackFrame → requestAnimationFrame). Now _scrubToFrame
  just sets the index and calls the callback, letting
  updatePlaybackFrame handle all redraws in a single rAF.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add distance-based fog shading to horizon profile

Backend (HorizonProfile.py):
- Track distance (meters) to the horizon point at each azimuth
- Output now includes [az, el, dist_m] per sample

Frontend (_drawHorizonCanvas):
- Parse distance from profile data (backward-compatible if missing)
- Replace uniform terrain fill with per-strip vertical fills
- Each strip's opacity mapped via log scale from distance:
  close horizon = opaque, far horizon = transparent
- Falls back to uniform fill if no distance data available

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase horizon profile fog opacity variation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): horizon profile now refreshes after new sweep completes

updatePlaybackFrame was using _horizonCache directly without validating
it against the current sweep center. After a new sweep set a different
sweepCenter, the stale cached profile was still drawn. Now routes through
fetchAndDrawHorizon which validates cache keys (lat/lng/height) and
refetches when the center has changed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add hover tooltip to horizon profile showing azimuth, elevation, and distance

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase horizon profile max radius from 5km to 50km

5km was too short to reach far crater rims on coarser DEMs, causing
the horizon profile to report deeply negative elevation angles where
the ray never found the actual skyline.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase horizon profile max radius to 250km

Also raised the backend cap from 100km to 500km to accommodate.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightline): add log stepping + early termination to horizon profile

Ray march now uses logarithmic step size (1px near, ~11px at 2500px)
so distant terrain is sampled more coarsely. Early termination stops a
ray once the maximum plausible terrain peak (10km, minus curvature)
at the current distance can't beat the already-found max elevation
angle. Together these reduce samples from ~2500/ray to ~200-600/ray
for 250km radius at 100m/pixel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add header with dual-handle log-scale horizon distance slider

Adds a 'Sightline Graphs' title and a dual-handle range slider to the
bottom panel header. The slider controls min/max horizon lookup distance
on a log scale (1m–250km). Dragging either handle invalidates the cache
and refetches the horizon profile with the new range. Default: 100m–250km.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): default horizon range to 1m–250km and add tippy tooltip

Changed min distance default from 100m to 1m. Added tippy tooltip on
the 'Horizon:' label explaining the dual-handle slider controls.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase crosshair circle and center dot radius by 1px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): deduplicate visibility chart time ticks and add user-select:none

When numTicks > frame count, multiple tick positions could round to the
same frame index, producing duplicate labels (e.g. two 'Jan 14 12:00').
Now skips any tick whose frameIdx matches the previous one. Also added
user-select: none to the time labels row.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): align visibility ticks with red slider position

- Cap numTicks to frame count (no more ticks than frames)
- Position ticks using frameIdx/(frameCount-1) — same formula as
  the red time slider — so they always line up exactly

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): set vstTimeStep width to 76px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): extract Sightline into dedicated backend module

Move sightmap and horizon profile endpoints from API/Backend/Utils/ into
a new API/Backend/Sightline/ module with its own setup.js, routes, and
scripts directory.

- POST /api/utils/sightmap → POST /api/sightline/sightmap
- POST /api/utils/gethorizonprofile → POST /api/sightline/horizonprofile
- private/api/sightmap.py → API/Backend/Sightline/scripts/sightmap.py
- private/api/HorizonProfile.py → API/Backend/Sightline/scripts/HorizonProfile.py
- Extract validateMissionsPath into shared API/validateMissionsPath.js
- Update frontend calls.js and E2E tests to use new paths

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): update SPICE kernel relative path after script relocation

The script moved from private/api/ to API/Backend/Sightline/scripts/,
so the relative path to spice/kernels/ needs 4 parent traversals
instead of 2.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs(sightline): update help with detailed algorithm descriptions

- Document sightmap viewshed algorithm: source position via SPICE,
  DEM composite, tangent-plane projection, ray-march viewshed, output grid
- Document horizon profile algorithm: per-azimuth ray march, elevation
  angle tracking, curvature correction, distance recording
- Add parameter tables for both endpoints
- Document performance methods: log stepping, early termination,
  managed resolution, batch streaming, frame limits
- Update SPICE paths to reflect new spice/ directory
- Update visibility timeline description (now pixel-based)
- Add fog shading, hover tooltip, and range slider to horizon profile docs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): extract modules for Indicators, Export, Horizon, Visibility

- Extract SightlineTool_Indicators.js (~689 lines): Az/El canvas gauges, sky dome, mini RAE
- Extract SightlineTool_Export.js (~488 lines): PNG, GIF, CSV, Grid export functions
- Extract SightlineTool_Horizon.js (~574 lines): horizon profile canvas, fog shading, tooltip
- Extract SightlineTool_Visibility.js (~278 lines): visibility timeline bar chart
- Extract RangeSlider reusable component to src/design-system/components/RangeSlider/
- SightlineTool.js reduced from 3082 to 1919 lines (thin delegates to new modules)
- SightlineTool_Graphs.js reduced from 1532 to 981 lines (delegates to Horizon/Visibility)
- Fix shadowReach isFinite validation bug (Devin Review feedback)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): invert color ramp to black→white, add horizon polygon overlay

- Invert WhiteBlack color ramp to BlackWhite (black→white gradient)
- Add faint horizon polygon on 2D map showing the horizon profile outline
  - Updates whenever horizon profile is fetched/redrawn
  - Removed when sightline graphs panel is closed
  - Very faint styling: white outline 0.35 opacity, fill 0.06 opacity

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add horizon polygon toggle checkbox, increase polygon opacity

- Add 'Polygon:' checkbox in graphs header bar (right of horizon range slider)
- Default off; toggling on shows the horizon polygon overlay on the 2D map
- Polygon is more visible: outline 0.6 opacity, fill 0.12 opacity, weight 1.5
- Checkbox state resets to off when graph panel closes
- Uses existing mmgis-checkbox component pattern

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): thicker polygon border (weight 3), add tippy on Polygon label

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): composite hover uses bounds-based lookup instead of tile projection

The _onCompositeHover function was using Globe_.litho.projection tile
coordinates (topLeftTile, tileResolution) which don't exist in the
sightmap data model. Replaced with bounds-based row/col computation
using data._bounds [west, south, east, north] which is what the
sightmap API actually returns.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): update azimuth lines on map pan/zoom

Azimuth SVG overlay uses pixel coordinates from latLngToContainerPoint.
When the map pans, these coordinates become stale but the overlay wasn't
being redrawn. Now listens for 'move' events on the map while the graph
panel is open and redraws the source azimuth lines on every move.
Listener is removed on panel close.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): composite hover uses projBounds for projected CRS maps

When the map uses a custom projected CRS, Leaflet e.latlng coordinates
are in projected meters, not geographic degrees. The hover function now
uses data._projBounds (projected) when in a projected CRS and
data._bounds (geographic) when in standard geographic CRS.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): update polygon tippy text, add user-select:none to labels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): project mouse coords before comparing to projBounds

Leaflet's e.latlng gives geographic lat/lng even in projected CRS maps.
For projected CRS (lunar south pole stereographic), we need to convert
these to projected coordinates via crs.project() before comparing
against data._projBounds. This was causing the hover to always produce
out-of-range row/col on projected CRS maps.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightline): progressive log2 stepping in sightmap ray march

Replace fixed-step march with distance-based progressive stepping:
step = base * max(1, log2(r+1)), combined with margin-based acceleration.

Near the observer every pixel is sampled. At r=1000px the step grows
to ~10x base; at r=25000px to ~15x base. This dramatically reduces
iterations for high-resolution DEMs (e.g. 10m USGS) where rays can
span 25,000+ pixels. Expected ~4-8x fewer samples per ray at distance
with negligible accuracy loss (distant terrain must be very tall to
affect the elevation angle).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "perf(sightline): progressive log2 stepping in sightmap ray march"

This reverts commit 97d29eecbbf9c8e7ede869fa5e8bfa4745871ed0.

* Revert "Revert "perf(sightline): progressive log2 stepping in sightmap ray march""

This reverts commit 975c772101aa0ebb4d161b82156100d77596e348.

* perf(sightline): add in-march early termination + reduce working DEM size

Two optimizations on top of the progressive log2 stepping:

1. In-march early termination (#4): at each sample, checks if the
   best-case terrain angle (MAX_TERRAIN_H minus curvature drop) at the
   current distance is below the source elevation. If so, no further
   terrain can block the source and the ray stops immediately. Most
   effective for high-elevation sources.

2. Match working DEM to output resolution (#5): reduced working_dim
   from 2x output to 1x output. Rays march through a ~400px array
   instead of ~800px, halving samples per ray and DEM I/O. Shadow
   accuracy is preserved since the output grid resolution is unchanged.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add 2-minute timeout to sightmap.py

Uses signal.SIGALRM on Unix and a threading.Timer fallback on Windows.
On timeout, raises TimeoutError which is caught by the existing error
handler and returns a JSON error response. Timeout is cancelled on
successful completion.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): prevent server crash on oversized sightmap output

Add a 256 MB stdout buffer cap with try-catch around string
concatenation. If the Python process output exceeds the limit,
the child process is killed and a 413 error is returned instead
of crashing the Node.js server with RangeError: Invalid string length.

This can happen at native resolution on large DEMs (e.g. 30993x30993)
where the output grid JSON exceeds Node's string size limit.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): binary grid encoding, NDJSON streaming, delta compression, gzip

Implement 7 output size reduction optimizations:
1. Binary encoding: uint8 grid → zlib compress → base64 (gridB64z field)
2. RLE via delta: batch frames encoded as XOR diffs (deltaB64z field)
3. Frontend decode: DecompressionStream → Uint8Array → 2D grid
4. Delta reconstruction: XOR previous flat with decoded delta
5. Precision: uint8 (0/1/9) instead of JSON number arrays
6. gzip: Node route compresses single-frame JSON; batch streams through zlib.createGzip()
7. NDJSON streaming: batch mode streams one JSON line per frame via chunked transfer

Backend (sightmap.py):
- compute_sightmap() returns gridB64z instead of grid array
- compute_sightmap_batch() streams NDJSON lines to stdout
  First frame: full gridB64z, subsequent: deltaB64z (XOR vs prev)
- Helper functions _encode_grid() and _encode_grid_delta()

Node route (sightmap.js):
- Batch: pipes child stdout through optional gzip to response stream
- Single: buffers stdout, optionally gzip-compresses before sending
- Content-Type: application/x-ndjson for batch, application/json for single

Frontend (SightlineTool.js):
- _decodeGridB64z(): atob → DecompressionStream('deflate') → Uint8Array → 2D grid
- _applyDelta(): XOR reconstruct from previous frame flat array
- Batch: uses fetch() with ReadableStream to parse NDJSON line-by-line
- Progressive progress updates as NDJSON frames arrive during streaming

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): remove Python-side timeout, Node manages 3min single / scaled batch

Python no longer has an internal 120s timeout that would kill long batch jobs.
Node.js is the sole timeout authority:
- Single-frame: 3 minutes (180s)
- Batch: max(3min, frameCount × 30s), capped at 30 minutes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): cap batch timeout at 5 minutes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: fix stale comment — batch timeout cap is 5 min, not 30

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): replace CSV export with GeoTIFF for all modes

- Static: single-band uint8 GeoTIFF (0=shadow, 1=lit, 9=nodata)
- Playback: multi-band uint8 GeoTIFF (one band per frame)
- Composite: single-band float32 GeoTIFF (0.0-1.0 visibility fraction)

Uses the existing geotiff.js writeArrayBuffer with WGS 84 geographic CRS,
ModelTiepoint and ModelPixelScale from the sightmap bounds. Output is a
proper georeferenced TIFF openable in QGIS, ArcGIS, etc.

Removes the old CSV export which caused RangeError on large grids
(400x400 x 24 frames = 3.84M rows). A 24-frame multi-band GeoTIFF is
~4MB vs ~190MB CSV.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): fix GeoTIFF export — use correct writeArrayBuffer input format

Single-band (static/composite): pass flat TypedArray directly (not wrapped
in array) so geotiff.js uses the flat code path with height/width metadata.

Multi-band (playback): pass native 2D arrays [band][row][col] so geotiff.js
can read dimensions from array structure.

Verified all three modes produce valid GeoTIFFs via gdalinfo.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* revert(sightline): remove GeoTIFF exports, drop playback CSV export

GeoTIFF exports had issues (geotiff.js bugs with float32/custom CRS).
Reverts to original CSV exports for static and composite modes.
Removes the playback CSV export entirely (caused RangeError on large grids).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.1.2-20260615 [version bump]

* feat: Phase 1 plugin restructure — unified /plugins/ directory

- Move 16 tools from src/essence/Tools/ → plugins/core/tools/
- Move 13 backends from API/Backend/ → plugins/core/backend/
- Move 1 component from src/essence/Components/ → plugins/core/components/
- Add plugins/core/plugin.json manifest
- Implement discoverPluginsUnified() three-level hierarchy scanner
- Update updateTools.js, setups.js, resolve-plugin-deps.js to use unified scan
- Update tool/component config.json paths for new locations
- Add plugins/ to webpack babel-loader include
- Replace 6 gitignore patterns with /plugins/* and !/plugins/core/
- Update AGENTS.md, CONTRIBUTING.md for new plugin structure
- Update test helpers and specs for new layout

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct relative require paths in moved backends

Backend files moved from API/Backend/ to plugins/core/backend/ need
updated relative paths to reach API/ modules (logger, connection, etc).
Also fix JSDoc comment containing '*/' in glob pattern that broke parser.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.1.3-20260615 [version bump]

* fix: escape glob pattern in JSDoc comment that broke parser

The pattern 'plugins/*/tools/' in a block comment contains '*/' which
prematurely terminates the /* */ comment block.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct rootDir path depth and plugin.json version

- utils.js and sightmap.js rootDir needs 5 levels of ../ (not 4) to
  reach repo root from plugins/core/backend/X/routes/
- Sync plugin.json version with package.json (5.1.3-20260615)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.1.4-20260616 [version bump]

* fix: update relative import paths for plugin directory restructure

All tools and components moved from src/essence/Tools/ and
src/essence/Components/ to plugins/core/tools/ and
plugins/core/components/ in the Phase 1 restructure. This commit
updates ~250 relative import paths in those files so they resolve
correctly from the new directory depth.

Also fixes:
- mmgisAPI LegendTool import (now points to plugins/core/tools/Legend/)
- missionTemplates test require path (now plugins/core/backend/Utils/)
- setups.js and resolve-plugin-deps.js reverted to unified scan only

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct SPICE kernel path depth in sightmap.py and address review findings

- sightmap.py PATH_TO_KERNELS: needs 5 '../' levels from new location
  (plugins/core/backend/Sightline/scripts/ is 5 deep, was 4)
- plugin.json version: sync to 5.1.4-20260616 matching package.json
- New Tool Template: move to plugins/core/tools/ with updated imports

Co-Authored-By: tariq.…
* fix(sightmap): correct azimuth CW/CCW convention in _sun_azel_batch

The east vector was computed as normal × north (= Up × N = -East = West),
causing atan2(dot(sun, west), dot(sun, north)) to return counter-clockwise
azimuths. This mirrored the result: geographic 240° CW reported as 120°.

Fix: use north × normal (= N × Up = East) per right-hand rule, matching
SPICE's azccw=False (clockwise from north) convention.

Verified: batch az now matches SPICE exactly (226.2493° vs 226.2493°).
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): use fixed-width fade gradient for visibility segments

The gradient on segment transitions was 30% of the segment width,
making wide segments have long fades and narrow segments short fades.
Now uses a fixed 2% of the total bar width for all transitions,
producing uniform fade-in and fade-out lengths regardless of segment size.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): wire resolution setting to maxOutputDim + gifshot fixes

Resolution dropdown (Low/Med/High/Ultra) now controls the sightmap
grid size sent to the backend:
  Static:  100 / 200 / 400 / 800 px
  Sweep:    50 / 100 / 200 / 400 px

Also:
- Add willReadFrequently to GIF export canvas contexts (suppresses
  Chrome warning about slow getImageData readback)
- Add gifshot progressCallback to update export progress during the
  GIF encoding phase (90→100%) instead of jumping at the end

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): remove dead SightlineTool_Manager and Layer specific DEMs config

SightlineTool_Manager.js was part of the old JS tile-fetching algorithm
(now replaced by the backend sightmap.py endpoint). Nothing imports it.

Remove:
- SightlineTool_Manager.js
- 'Layer specific DEMs' config section (variables.data with demtileurl,
  minZoom, maxNativeZoom, boundingBox) from config.json and all blueprint
  mission configs
- vars.data validation in initialize() — replaced with vars.dem check

The Viewshed tool's separate Manager and its layer-level demtileurl
references are unaffected.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): downsample large DEMs at read time via GDAL decimation

High-res DEMs (e.g. 100m vs 4000m) were read fully into memory even when
the output grid was small, causing:
1. Slow computation (full array I/O + march through many more pixels)
2. Windows pickle truncation on multiprocessing (huge arrays exceed
   pickle buffer limits when serialized to spawn'd worker processes)

Fix: open_dem() now accepts max_working_dim and uses GDAL's ReadAsArray
with buf_xsize/buf_ysize for server-side bilinear decimation. The
geotransform is adjusted to match the resampled pixel grid. Working dim
is set to max(max_output_dim * 4, 1000) — 4x oversampling for terrain
detail in the ray march, capped at native resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): GDAL overview bands, DEM caching, Numba warmup, reduce working_dim

Three optimizations to cut sightmap generation time on large DEMs:

1. GDAL overview bands: If the DEM has pre-computed overviews (COGs),
   read from the overview band directly instead of decimating the full
   raster in memory. Falls back to ReadAsArray(buf_xsize/ysize) for
   DEMs without overviews.

2. DEM caching: Module-level cache keyed by (path, working_dim) avoids
   re-reading the same DEM within a batch run. LRU eviction at 4 entries.

3. Numba JIT warmup: Trigger compilation at module import with a tiny
   2x2 dummy array so the ~5-10s first-compilation cost is paid during
   startup, not during the actual sightmap computation.

4. Reduce working_dim from 4x to 2x output grid (min 500px instead of
   1000px). Halves the march distance and array sizes while maintaining
   sufficient terrain detail for shadow boundary accuracy.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: add timing instrumentation to sightmap.py

Temporary timing logs to stderr at each phase:
- imports, numba_warmup, load_kernels, spice_azel, unload_kernels
- open_dem (with working_dim and actual array size)
- _precompute_grid, _compute_sun_grid, _compute_directions
- _numba_march, total

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: pipe sightmap stderr to Node console for timing visibility

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: include timing data in sightmap JSON response body

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): disk-based .npy cache for decimated DEMs

The timing data showed open_dem takes 12.8s (GDAL reading/decimating a
large DEM) while the actual Numba march takes only 0.065s.  Since each
sightmap call spawns a new Python process, the in-memory cache is lost.

Fix: after the first GDAL decimate, save the resulting numpy array and
geotransform metadata to .npy/.json files next to the source DEM.
Subsequent process invocations load the pre-decimated array via np.load
(~0.01s).  Cache is invalidated if the source DEM file is newer.

Expected improvement: first run ~14s (unchanged), second+ runs ~2s.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): require COG format for large DEMs, remove npy cache

Replace the disk-based .npy cache with a simpler approach: require the
DEM to be a Cloud Optimized GeoTIFF (COG) when decimation is needed.
COGs have internal tiled layout + overview pyramids so GDAL can read at
any target resolution by seeking to the right bytes — no full-file scan.

If the DEM is not a COG and is large enough to need decimation, throw a
clear error with the gdal_translate command to convert it.

Small DEMs that fit within max_working_dim are read directly regardless
of format (no performance issue at small sizes).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: pass through Python error messages to frontend on sightmap failure

When sightmap.py exits with code!=0, the Node handler was returning a
generic 'sightmap computation failed' message. Now it tries to parse
the structured JSON error from stdout first, so the actual error
(e.g. 'DEM is not a COG') reaches the frontend.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sanitize COG error message — no paths or tracebacks in response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove traceback from JSON error response, log to stderr only

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: show actual sightmap error message in toast

- calls.api error callback now parses JSON response body from jqXHR
- SightlineTool error handlers display server error message (e.g. COG)
- No traceback or paths in error responses (security)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove duplicate 'sightmap error' prefix from Python error messages

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): temporarily disable multiprocessing for batch mode

Run all timestamps sequentially in the main process so the DEM stays
in memory and avoids pickle serialization overhead per worker.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightmap): remove multiprocessing and Resolution UI option

- Remove all multiprocessing infrastructure (pool, workers, shared state)
- Batch timestamps run sequentially in the main process
- Remove Resolution dropdown from UI, default to ultra (800px static, 400px sweep)
- Always auto-regenerate on map move (no resolution-gated cutoff)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: temporarily disable Numba JIT for benchmarking comparison

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: re-enable Numba JIT after benchmarking (saves ~6s per frame)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: re-add timing instrumentation to sightmap response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: compute grid convergence analytically, skip GDAL TransformPoints

_compute_directions was calling _pixels_to_geo_batch on all 940K cells
(969x969 grid) to get each cell's longitude for the convergence angle.
This took ~1s (29% of total).

Now computes convergence directly from projected coordinates using
atan2(x - false_easting, sign*(y - false_northing)) — pure numpy
math, no GDAL coordinate transform. Expected: ~0.01s.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: skip kernel unloading + increase coarse subgrid step to 50

- Remove spiceypy.unload() calls — process exits immediately after
  response so OS cleanup is sufficient. Saves ~0.235s.
- Increase COARSE_AZEL_STEP from 10 to 50 — reduces coarse subgrid
  points from ~9400 to ~400 for sun az/el interpolation. Sun position
  varies slowly across the DEM so fewer sample points suffice.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap.py

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: interpolate sun azimuth in sin/cos space to prevent wrap artifacts

When coarse subgrid has azimuth values near the 360°/0° boundary
(e.g. 355° and 5°), linear interpolation produces ~180° — completely
wrong direction that flips shadows. Now interpolates sin(az) and
cos(az) separately, then recovers the angle via atan2. This handles
the circular wrap correctly.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: viewport-aware sightmap — clip DEM read to visible area at native resolution

Frontend sends current map viewport bounds (projected coords) with each
static sightmap request. Backend clips the DEM read to the viewport
intersection, reading at native resolution (capped at maxOutputDim).

When zoomed in, this means the sightmap covers just the visible area
but at much higher resolution than the full-DEM downsampled version.
When zoomed out, viewport encompasses the full DEM and behavior is
unchanged.

For a 30993x30993 DEM zoomed to 1/10th coverage, instead of reading
the full raster decimated to 1600x1600, we read only ~3000x3000 native
pixels for the visible window — higher res and faster.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: compute viewport projected bounds from container corners, not lat/lng bbox

In polar stereographic CRS, the lat/lng bounding box from
getBounds() maps to a distorted region in projected space.
Now samples all 4 container pixel corners, projects each through
the CRS, and takes the envelope for correct projected bounds.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: position sightmap overlay using projected NW/SE corners directly

In polar/rotated CRS, L.latLngBounds normalises by min/max lat/lng,
which shuffles corners — getNorthWest() and getSouthEast() return
points that don't correspond to the projected rectangle's NW and SE.
The overlay is then mispositioned and stretched.

New _projImageOverlay() helper overrides the overlay's _reset method
to compute pixel position from the projected NW (xmin, ymax) and
SE (xmax, ymin) corners via latLngToLayerPoint, bypassing the
normalisation entirely.  Applied to all three overlay creation paths:
static sightmap, heatmap, and sweep frame.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: override _animateZoom on projected overlay to prevent zoom jump

The default _animateZoom reads the normalised L.latLngBounds which
has wrong corners in polar CRS, causing the overlay to briefly jump
to the top-left during zoom transitions.  Now _animateZoom uses the
projected NW corner via _latLngToNewLayerPoint for correct animation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: wire viewport bounds through batch/sweep mode

Frontend sweep call now sends viewportBounds.  Backend
compute_sightmap_batch accepts and passes viewport_bounds
to open_dem so playback also clips to the visible DEM region
at native resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: equalize sweep/static resolution + show 'Sweeping' on progress button

- _resolutionToMaxDim now returns 800 for both modes (was 400 for sweep)
- ProgressButton label shows children text alongside percentage
- SightlineElement shows 'Sweeping'/'Generating' while loading

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: actually commit _resolutionToMaxDim change to equalize sweep/static res

Was missed from the previous commit — sweep was still getting 400 instead of 800.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: re-fetch horizon profile after pan so charts stay in sync

When the user panned, invalidateHorizonCache set _horizonCache to null
but never triggered a re-fetch.  Subsequent scrub or playback frame
changes found the cache empty and either skipped the horizon redraw or
drew the visibility timeline with no profile (marking everything not
visible).

- _onPanEnd now calls invalidateAndRefetch() which re-fetches the
  horizon profile if the graph panel is open.
- _scrubToFrame and updatePlaybackFrame fall back to fetchAndDrawHorizon
  when the cache is null instead of drawing with missing data.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile azimuth for projected CRS (polar stereo)

HorizonProfile.py was marching along pixel/raster-space directions,
treating pixel-up as north.  In polar stereographic the grid north
axis is rotated from true north by the grid convergence angle, so the
profile azimuths were offset and the terrain silhouette appeared
rotated in the chart.

Added _grid_convergence() which computes the convergence at the
observer's projected coordinates via atan2(x - FE, -(y - FN)).  Each
geographic azimuth is now rotated by the convergence before marching
in pixel space, making the profile azimuths true geographic azimuths.
No change for geographic (unprojected) CRS.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile convergence formula for polar stereo

Two bugs in the previous convergence fix:

1. _grid_convergence used atan2(x, -y) unconditionally, but for
   south-pole stereo the sign should be +1 (north is away from
   pole = positive y). Now uses north_sign = -1 for north-pole,
   +1 for south-pole, matching sightmap.py exactly.

2. The convergence was subtracted (geo_az - convergence) when it
   should be added (geo_az + convergence), matching sightmap.py's
   convention where convergence rotates from grid north to true
   north.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: extract rate limiters into shared scripts/rateLimiters.js module

- Create scripts/rateLimiters.js exporting apilimiter, authLimiter, computeLimiter
- Remove inline rateLimit() definitions from scripts/server.js
- Remove authLimiter/computeLimiter from the 's' setup object
- Update Users/routes/users.js: import authLimiter directly, replace late-binding wrapper
- Update Users/setup.js: remove router._authLimiter assignment
- Update Utils/routes/utils.js: import computeLimiter directly, replace all 8 late-binding wrappers
- Update Utils/setup.js: remove router._computeLimiter assignment
- Update unit tests to import from the shared module

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.26-20260609 [version bump]

* fix: use geodesic destination for azimuth lines instead of angle rotation

Replace the _localNorthAngle + screen-space rotation approach with a
direct forward geodesic method: compute a destination lat/lng 1° along
the desired azimuth, project it through Leaflet, and draw the line.

This avoids potential compound angle errors and works correctly for
any CRS because it uses Leaflet's own projection pipeline end-to-end
rather than computing a screen-space north-offset angle and rotating.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: convert reference mission DEMs to Cloud Optimized GeoTIFF (COG)

Both the Earth (USGS SF Hill) and Lunar South Pole (LRO LOLA 4000m)
DEMs are now tiled COGs with deflate compression and overviews.
This ensures consistent behavior with the sightmap COG requirement
and enables fast overview-based reads at any resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add lunar LSMT to chronice, fix TimeUI indicator cleanup and observer time sync

- chronice.py: add lunar LSMT support using SPICE et2lst with observer
  longitude; format: LDAY-NNNNNLHH:MM:SS; reverse conversion via iterative
  refinement
- utils.js: pass optional lng param through to chronice.py
- SightlineTool.js: remove TimeUI indicator on mode switch, cancel sweep,
  resweep start, and pan-end; pass lng from observer point for LSMT observers
- SightlineElement.jsx: update global TimeControl when observer time inputs
  are changed (blur/Enter), fixing Mars SOL time not updating the TimeUI
- Lunar ref mission config: add Moon (LSMT) observer with type=lsmt

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: LSMT lng fallback to map center, hide empty DEM dropdown, Enter key on observer time

- _getObserverLng: fall back to map center when indicatorLastDragPoint is null
- SightlineElement: hide DEM dropdown when no data options configured
- SightlineElement: add onKeyDown Enter handler on observer time inputs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Improve Mars Reference Mission

* chore: bump version to 5.0.28-20260610 [version bump]

* fix: sightmap overlay CRS mismatch for non-custom projections, restore config descriptions

- Only use _projImageOverlay and viewport clipping when the mission uses
  a custom projected CRS (projection.custom=true). For standard longlat/
  Mercator missions (like Mars), the DEM's projected bounds are in a
  different CRS than the map, causing misplaced overlays.
- Restore Layer-specific DEMs config row and improve field descriptions
  in sightline tool config.json (lost during ShadeTool→SightlineTool rename).
- Add sweepColorRamps and observer type examples to descriptionFull.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: restore sightline config descriptions from ShadeTool, remove data row, clear default name

- Remove Layer-specific DEMs config row (previously asked to remove)
- Restore detailed descriptions from old ShadeTool config:
  - Sources: documents name/value properties, dropdown usage, kernel path
  - Observers: documents name/value/frame/body, chronos setup path
  - Default Height: full description of height parameter behavior
  - Observer Time Placeholder: documents format string usage
  - Frame/Body fields: proper SPICE reference descriptions
- Remove 'Sightline N' default element name (now empty)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: observer time input 7hr drift — chronice result parsed as local instead of UTC

Root cause: chronice lmst→utc returns '2026-05-30T21:36:57.975' (no Z suffix).
The old ShadeTool correctly did: result.replace(' ', 'T') + 'Z'
The new code used a regex chain that failed when milliseconds were present
without a trailing Z, leaving the string timezone-ambiguous. new Date()
then parsed it as local time (UTC-7), adding ~7 hours.

Fix: strip milliseconds then unconditionally append Z, matching the old
ShadeTool approach. The /ZZ$/ → Z guard prevents double-Z if chronice
ever returns a Z-suffixed result in the future.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sightmap sun direction for cylindrical projections, 1s time drift, tab-switch regen, add editable time field

1. Sightmap sun direction: _compute_directions now only applies convergence
   rotation for azimuthal projections (stereo/gnomonic). For cylindrical
   projections (Equidistant Cylindrical, Mercator), grid north = geographic
   north so convergence = 0. Previously applied polar-stereo formula to all
   projected CRS, giving ~90deg rotation on Mars DEM.

2. Observer time 1-second drift: restored _lastConvertedMs pattern from old
   ShadeTool. Saves sub-second precision from observer->UTC conversion and
   re-attaches it in UTC->observer reverse conversion for exact round-trips.

3. Tab-switch regeneration: _onTimeChange now tracks _lastGeneratedTime and
   skips if unchanged, preventing redundant sightmap computation when
   TimeControl re-broadcasts the same time on tab refocus.

4. Editable time field (vstOptionTime): restored from old ShadeTool. Shows
   current end time in configured format (DOY, etc), editable on blur/Enter.
   Parses via utcTimeFormat if configured, else appends Z directly.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: match old ShadeTool vstOptionTime styling and DOY format

- CSS matches old ShadeTool exactly: full-width centered input, bold 14px,
  color-p0 bg, color-a1-5 text, transparent border that shows color-c on focus
- Clock icon positioned absolute right (pointer-events: none) as in original
- Structure uses flexbetween wrapper matching old jQuery markup
- Mars reference mission utcTimeFormat changed to DOY: '%Y-%j %H:%M:%S'
  giving output like '2026-150 21:36:57' instead of ISO format

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* ui: hide observer start time in static mode, show only 'Time' field

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: horizon profile rotation for cylindrical CRS + visibility chart text non-selectable

1. HorizonProfile.py _grid_convergence: same fix as sightmap.py — only
   apply convergence for azimuthal projections (stereo/gnomonic). For
   cylindrical projections (Mars Equidist. Cylindrical), convergence = 0,
   so horizon terrain profile azimuths are now correct.

2. Visibility chart (.sightlineVisWrap): added user-select: none so
   dragging the timeline scrubber doesn't accidentally highlight text.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: horizon profile aspect ratio for geographic CRS + crosshair styling/lag

- HorizonProfile.py: compute per-axis pixel scales (px_scale_x, px_scale_y)
  so the march direction accounts for longitude compression at observer
  latitude. For geographic CRS at 38°N, 1° lon ≈ 0.79 × 1° lat in meters;
  without this the march traces wrong physical angles, distorting azimuths.
  Also computes correct per-step physical distance instead of using the
  averaged pixel_scale.

- Crosshair restyled: smaller (8px circle, 5px arms), lime green with
  black borders (box-shadow outline).

- Crosshair converted from raw DOM element to Leaflet DivIcon marker.
  Leaflet handles positioning in its own transform pipeline, eliminating
  the lag that occurred when updating CSS left/top on the move event.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add lime center dot at visible map center while sightline tool is open

Small 6px lime green dot with 1px black border, always at 50%/50% of
the map container (CSS-only positioning, no event tracking needed).
Added on make(), removed on destroy().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: update crosshair position immediately when sweepCenter is set

Previously the crosshair only corrected its position on the next pan
event. Now _updateCrosshairPosition() is called right after sweepCenter
is stored for both static sightmap and batch/sweep completion.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sightmap route security + batch limits + test fix (Devin Review)

- Add SAFE_NAME_RE validation on target, obsRefFrame, obsBody to prevent
  directory traversal via SPICE kernel paths (matches /ll2aerll_bulk).
- Add MAX_TIMES=200 cap on sightmap batch to prevent resource exhaustion.
- Fix E2E test: batch response is a raw JSON array, not { results: [...] }.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: add error handler + headersSent guards on sightmap spawn

Match ll2aerll_bulk pattern: handle child.on('error') and
child.stdin.on('error') to prevent hung responses if Python
fails to start. Add !res.headersSent guards on all response
paths in the close handler.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: get_pixel_scale uses actual array rows instead of ds.RasterYSize

After open_dem decimates a large DEM, gt[5] is scaled but ds still has
the original RasterYSize. Using ds.RasterYSize with the decimated gt
produces a wrong mid_lat for geographic CRS pixel scale. Now accepts
dem_rows directly from dem.shape.

Also: encodeURIComponent the chronice lng argument to match the other
CLI args (consistency with unquote() on the Python side).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct East vector cross product order in sun_azel_at_cell

cross(normal, north) yields West, not East. Changed to
cross(north, normal) to match the batch version _sun_azel_batch.
Currently unused at runtime but prevents future bugs.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove dead code from sightmap.py

Removed unused scalar functions that were superseded by vectorized
equivalents: sun_azel_at_cell (replaced by _sun_azel_batch),
is_nodata (replaced by _vectorized_is_nodata), geo_to_pixel (never
called). Also removed the unused ds parameter from open_dem return
value and _compute_bounds signature — ds was only kept alive for
get_pixel_scale which no longer needs it after the dem_rows fix.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove dead code from SightlineTool frontend

SightlineTool.js: removed showSightlinemapLayers, showSweepLayers,
refreshAllHeatmaps, _nextPow2 — all defined but never called.

SightlineTool_Algorithm.js: removed the entire old client-side
sightline algorithm (sightline, processUp/Down, mask, curveData,
isNoData, compositeResults, calcHeight*, initializeGrids, perOctant)
and their unused imports (jquery, F_, L_, G_). Only
cumulativeVisibility is called externally; all other methods were
from the pre-backend era and superseded by sightmap.py.

SightlineTool_Graphs.js: removed _localNorthAngle, replaced by
the geodesic _destinationPoint + _azimuthEndpoint approach.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: color picker/ramp dropdown clipping + reorder default colors

Remove overflow:hidden from vstSightlineItem, vstSweepCard, and
vstSweepCardsSection so absolutely-positioned color picker palettes
and color ramp dropdowns are no longer clipped by their parent
containers. Add border-radius to headers directly to preserve
rounded corners.

Bump vstColorPalette z-index from 100 to 10000 to match the
ColorRampPicker popup z-index.

Reorder MULTI_SOURCE_COLORS: yellow -> blue -> red -> green
(swapped blue and red positions).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: improve LSMT inverse conversion precision from ~30s to <1s

et2lst returns integer (hr, mn, sc) so one lunar second spans ~29 ET
seconds. The old iterative loop converged to ±1 lunar second, giving
~30s UTC precision. Now uses binary search after the coarse loop to
find the exact ET boundary where the second ticks over, narrowing to
<0.5 ET seconds. Result is placed at the midpoint of the lunar
second window for minimal round-trip error.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: color picker palette uses fixed positioning to escape overflow

The Collapsible panel has overflow:hidden for its open/close
animation, which clips the color picker dropdown. Changed the
palette to position:fixed, computed from the swatch's bounding
rect on click, so it escapes all overflow containers.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: revert fixed positioning, use overflow:visible on open panels

Reverts position:fixed approach. Instead overrides overflow to
visible on open Collapsible panels inside sightlineTool via
[data-open] selector, so the color palette can extend past the
panel boundary while keeping overflow:hidden during animations.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): implement 6 improvements to Sightline tool

1. Combine Playback and Composite Sweeps: always build both heatmap
   and atlas after sweep; mode switching is instantaneous without
   re-running the sweep.

2. Min/Max Distance options: add UI inputs and pass minDistance/
   maxDistance through to sightmap.py ray-march kernel and
   HorizonProfile.py. Includes curvature-based early termination.

3. Better Color Ramps + Fix Transparency Bug: expose sweepColorRamps
   in admin config, reorder defaults, fix evalColor/evalColorWithStops
   discrete bin index calculation (Math.floor -> Math.round).

4. Draggable Horizon Profile Point: crosshair marker is now interactive
   and draggable; on dragend updates indicatorLastDragPoint and refetches
   horizon profile at the new location.

5. Vis Chart - Remove Gradients: remove gradient transition logic from
   drawVisibilityTimeline; uncertain regions shown as occluded.

6. Document Algorithms: add detailed algorithm documentation to
   sightmap.py and HorizonProfile.py headers.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.1.1-20260611 [version bump]

* fix: resolve merge conflict in configure/package.json

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): set mode before sweepShowAllFrames, add distance to horizon cache key

- switchElementMode now sets sightlineMode='playback' before calling
  sweepShowAllFrames, which filters by sightlineMode.
- Horizon profile cache now includes maxDist/minDist so changing distance
  parameters invalidates the cache and triggers a re-fetch.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): address 4 review issues

1. Default color ramps: add inferno + viridis to front of defaults;
   remove sweepColorRamps from all reference mission configs so they
   use the built-in defaults.

2. Mode switching: keep Results section open when switching between
   composite/playback (don't collapse if sweep data exists); call
   sweepShowFrame directly for the element when switching to playback.

3. Crosshair dragging: use Leaflet.Editable (enableEdit/disableEdit)
   instead of L.marker draggable option; listen on 'editable:dragend'.
   Map click handler now only triggers static sightline when element
   is in static mode - prevents unwanted resweep in playback mode.

4. maxDistance fix: backend route (utils.js) was not passing
   minDistance/maxDistance through to the Python sightmap.py stdin
   payload. Added both fields to payloadObj.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): hardcode 6 color ramps, revert crosshair to non-draggable

1. Color ramps: remove custom/configurable sweepColorRamps entirely.
   Hardcode exactly 6 ramps:
   - [transparent, color]
   - [transparent, color, transparent]
   - Inferno
   - Viridis
   - Red → Green (RdYlGn)
   - Black → White (Greys)

2. Crosshair: revert to original non-draggable behavior (interactive: false).
   Remove indicatorLastDragPoint from store and all references.
   Horizon profile and visibility chart now always use the current map
   center — they auto-update on pan end via the existing
   invalidateAndRefetch() call in _onPanEnd.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): reverse B&W ramp to White→Black, single color stop

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): anchor horizon profile to sweep center when available

Revert the horizon profile to use sweepCenter when a sweep exists,
falling back to the current map center when no sweep has been run.
This keeps the horizon chart, entity arcs, and visibility timeline
all consistent with the sweep observer location.

Skip horizon invalidation on pan when anchored to sweep center
to avoid unnecessary backend calls.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightline): add timing instrumentation to sightmap pipeline

Python backend (sightmap.py):
  - Timing for: kernel loading, SPICE az/el, DEM open, grid precompute,
    per-frame sun grid + march + tolist, json.dumps, total
  - Batch response now returns {results, _timing} with per-frame arrays
  - DEM/output dimensions logged for context

Node.js route (utils.js):
  - Log total spawn-to-close time, JSON parse time, stdout size

Frontend (SightlineTool.js):
  - Log API round-trip, grid parsing, heatmap compute, atlas build,
    total frontend processing
  - Console output tagged [Sightmap Timing] / [Sightmap Sweep]

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* TEMP: strip grid data from sightmap response for timing debug

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): cache frame-invariant data in batch mode

Pre-compute and cache across all frames:
- Coarse grid lat/lng (coordinate transform done once, not per-frame)
- Bilinear interpolation weights (indices + weight arrays)
- Ellipsoid geometry for az/el (cell positions, normals, north/east vectors)
- Grid convergence angle for azimuthal projections

Per-frame now only computes:
- Source direction vector subtraction on ~119 coarse points
- 3x bilinear weight-apply (fast matmul, no index recomputation)
- sin/cos for direction on full grid

Expected speedup: source_grid from ~116ms/frame to ~15-25ms/frame
(~5-8x faster for 145 frames: ~17s → ~2-4s)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): eliminate full-grid trig via angle addition formula

Instead of: bilinear interp sin/cos → arctan2 → +convergence → radians → sin/cos
Now:        bilinear interp sin/cos → multiply by cached sin/cos(convergence)

Removes 6 numpy operations on the full 240K-cell grid per frame
(arctan2, where, add, radians, sin, cos) and replaces them with
4 multiply + 2 add operations (cheaper element-wise math).

Also computes coarse az as normalized sin/cos components directly
from the dot products, skipping degrees/radians conversions on
the coarse grid too.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add resolution dropdown + restore grid data

Add viewport-relative resolution control (1×, 0.5×, 0.25×, 0.125×)
to the Display section. Default is 0.25× (medium). The scale factor
multiplies the viewport's longest pixel dimension to produce
maxOutputDim sent to the backend.

- 1× = native (no decimation beyond viewport crop)
- 0.5× = half viewport dims
- 0.25× = quarter (default, balanced speed/quality)
- 0.125× = eighth (fastest, coarsest)

Server-side clamp raised from 800 to 4096 to support 1× on large
viewports.

Also restores grid data in sightmap responses (reverts the TEMP
debug strip).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightmap): scale frame limit by resolution

1× (maxDim≥800): 256 frames
0.5× (maxDim≥400): 512 frames
0.25× (maxDim≥200): 1024 frames
0.125× (maxDim<200): 2048 frames

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightmap): replace times array with startTime/endTime/stepSeconds

Send 3 scalar values instead of potentially 2000+ timestamp strings.
Backend generates timestamps internally from the range.

- Frontend sends ISO timestamps (e.g. '2026-06-11T19:53:00Z') and
  stepSeconds (e.g. 60)
- Node.js route validates the range and computes frame count for
  limit checking
- Python parses ISO start/end, generates time strings with timedelta
- Frame limit raised to 2048 max (at 0.125× resolution)
- Shrinks request payload from ~100KB to ~500B for large sweeps

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): range circles, max distance infinity toggle, viewport padding

- Draw dashed min/max distance circles on map when values are non-zero
  (orange for min, blue for max). Circles update on value change and
  center on sweep observer position when available.

- Add ∞ toggle button on Max Dist row. When active, sends maxDistance=-1
  to backend which skips viewport cropping and reads the full DEM (at
  the appropriate overview level for the resolution setting).

- Add Tooltip on Max Dist label explaining viewport-only vs full DEM
  behavior.

- Backend: when maxDistance > 0, pad viewport bounds by that distance
  in all directions so shadow-casting terrain beyond the visible extent
  is included. When maxDistance=-1 (infinity), viewport_bounds is set
  to None so the full DEM is read.

- Store: add maxDistInfinity per-element field (default false).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: range circles use projected polygon, infinity mode preserves viewport resolution

Range circles:
- Replace L.circle (broken on custom planetary CRS) with L.polygon
  built from 64 vertices computed in projected coordinates via
  crs.project/unproject. Works correctly on polar stereo and any
  custom L.Proj.CRS.

Infinity mode resolution fix:
- Separate DEM read extent from output grid extent. When infinity
  mode is active (maxDistance=-1), the full DEM is loaded at overview
  resolution for shadow tracing, but the output grid is sized to the
  viewport subset only — preserving the same cell density as normal
  mode.
- When maxDistance > 0 (finite padding), DEM read bounds are padded
  by maxDistance in all directions. Output grid still viewport-only.
- Both compute_sightmap and compute_sightmap_batch support
  output_offset_row/col parameters that offset output grid cells
  within the loaded DEM. Coarse subgrid, precompute, and batch
  context all respect the offset so rays trace through the full
  loaded DEM while only evaluating viewport cells.
- Add _compute_bounds_from_proj() to compute geographic bounds from
  projected viewport coords for correct response bounds.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: replace min/max distance fields with DEM Extent dropdown

Remove minDistance, maxDistance, maxDistInfinity fields and range
circles. Replace with a simple 'DEM Extent' dropdown (Viewport / Full
DEM) in the Display section, defaulting to Viewport.

- Viewport: uses only the DEM visible on screen (fast)
- Full DEM: reads the entire raster for distant shadow casting (slower,
  sends maxDistance=-1 to backend)

Removes ~120 lines of range circle rendering code that is no longer
needed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "refactor: replace min/max distance fields with DEM Extent dropdown"

This reverts commit b3d6cc503a170a522f48bdd7f5b21d723b9612e7.

* Revert "fix: range circles use projected polygon, infinity mode preserves viewport resolution"

This reverts commit 6b265aa823e9634e13d5857119826b174360d384.

* Revert "feat(sightline): range circles, max distance infinity toggle, viewport padding"

This reverts commit fd0c5c2262d9c2bccffa69102709be759333c1ad.

* feat(sightline): add Shadow Reach LOD field for distant shadow casting

Add a 'Shadow Reach' input (km) to the Display section that extends
terrain loaded for shadow computation beyond the visible viewport.
When set, the backend reads the viewport DEM at full resolution and
a padded border region at a coarser COG overview level, composites
them into a single array, and marches rays through the full composite
while outputting only the viewport portion.

- New open_dem_composite() reads viewport + low-res border
- Output grid offset support in _precompute_grid_arrays,
  _compute_sun_grid, _precompute_batch_grid_context
- Replaces minDistance/maxDistance with single shadowReach parameter
- Tooltip explains the viewport-extension concept
- Default 0 = viewport only (no performance impact)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): prevent OOM on large shadow reach, clamp to curvature, blur-only regen

- Redesigned open_dem_composite(): single DEM read of the padded
  region at a managed resolution (scales working_dim by viewport/pad
  ratio, capped at 4× max_working_dim) instead of building a massive
  array at viewport pixel scale then upsampling.
- Shadow reach clamped to planetary curvature limit: sqrt(2*R*h_max)
  where h_max=10km. For Moon (R=1737km) this caps at ~186km.
- Shadow Reach input only triggers sightmap regen on blur (unfocus),
  not on every keystroke.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): Enter key triggers regen on Shadow Reach input

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): respect drag order for sightmap z-index, fix drop indicator position

- After rendering a sightmap overlay, re-apply z-order based on the
  current element order in the store so the panel ordering is respected
  regardless of which sightmap finishes loading first.
- Fix drop indicator: changed border-bottom to border-top so the line
  appears above the target element (matching where the drop will place
  the dragged item).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): position-aware drag-and-drop indicator (above/below)

The drop indicator now shows border-top when hovering the upper half
of a target element (insert above) and border-bottom when hovering
the lower half (insert below). The drop logic uses the cursor's Y
position relative to the element midpoint to determine placement.

Applied to both SightlinePanel element cards and SweepSection cards.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): visibility timeline uses sightmap grid pixels, not horizon profile

- Visibility chart now checks the observer's pixel in each frame's
  sightmap grid (lit=1/2 → visible, shadow=0 → occluded) instead of
  comparing source az/el against the horizon profile.
- Observer pixel computed from projected coordinates (using CRS
  projection) when available, falling back to geographic bounds.
- Replaces horizon profile interpolation in drawVisibilityTimeline
  with direct grid pixel lookup via centerVisible.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore(sightline): remove timing logs and fix graph slider performance

- Remove all timing instrumentation from sightmap.py (timing dict,
  perf_counter calls, stderr serialization log, import time)
- Remove _timing from sightmap API responses (single + batch)
- Remove all console.log timing calls from SightlineTool.js
- Fix graph time slider performance: _scrubToFrame was redrawing
  horizon + visibility canvases synchronously AND triggering the
  same redraws again via the scrub callback (sweepShowAllFrames →
  updatePlaybackFrame → requestAnimationFrame). Now _scrubToFrame
  just sets the index and calls the callback, letting
  updatePlaybackFrame handle all redraws in a single rAF.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add distance-based fog shading to horizon profile

Backend (HorizonProfile.py):
- Track distance (meters) to the horizon point at each azimuth
- Output now includes [az, el, dist_m] per sample

Frontend (_drawHorizonCanvas):
- Parse distance from profile data (backward-compatible if missing)
- Replace uniform terrain fill with per-strip vertical fills
- Each strip's opacity mapped via log scale from distance:
  close horizon = opaque, far horizon = transparent
- Falls back to uniform fill if no distance data available

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase horizon profile fog opacity variation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): horizon profile now refreshes after new sweep completes

updatePlaybackFrame was using _horizonCache directly without validating
it against the current sweep center. After a new sweep set a different
sweepCenter, the stale cached profile was still drawn. Now routes through
fetchAndDrawHorizon which validates cache keys (lat/lng/height) and
refetches when the center has changed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add hover tooltip to horizon profile showing azimuth, elevation, and distance

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase horizon profile max radius from 5km to 50km

5km was too short to reach far crater rims on coarser DEMs, causing
the horizon profile to report deeply negative elevation angles where
the ray never found the actual skyline.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase horizon profile max radius to 250km

Also raised the backend cap from 100km to 500km to accommodate.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightline): add log stepping + early termination to horizon profile

Ray march now uses logarithmic step size (1px near, ~11px at 2500px)
so distant terrain is sampled more coarsely. Early termination stops a
ray once the maximum plausible terrain peak (10km, minus curvature)
at the current distance can't beat the already-found max elevation
angle. Together these reduce samples from ~2500/ray to ~200-600/ray
for 250km radius at 100m/pixel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add header with dual-handle log-scale horizon distance slider

Adds a 'Sightline Graphs' title and a dual-handle range slider to the
bottom panel header. The slider controls min/max horizon lookup distance
on a log scale (1m–250km). Dragging either handle invalidates the cache
and refetches the horizon profile with the new range. Default: 100m–250km.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): default horizon range to 1m–250km and add tippy tooltip

Changed min distance default from 100m to 1m. Added tippy tooltip on
the 'Horizon:' label explaining the dual-handle slider controls.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase crosshair circle and center dot radius by 1px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): deduplicate visibility chart time ticks and add user-select:none

When numTicks > frame count, multiple tick positions could round to the
same frame index, producing duplicate labels (e.g. two 'Jan 14 12:00').
Now skips any tick whose frameIdx matches the previous one. Also added
user-select: none to the time labels row.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): align visibility ticks with red slider position

- Cap numTicks to frame count (no more ticks than frames)
- Position ticks using frameIdx/(frameCount-1) — same formula as
  the red time slider — so they always line up exactly

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): set vstTimeStep width to 76px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): extract Sightline into dedicated backend module

Move sightmap and horizon profile endpoints from API/Backend/Utils/ into
a new API/Backend/Sightline/ module with its own setup.js, routes, and
scripts directory.

- POST /api/utils/sightmap → POST /api/sightline/sightmap
- POST /api/utils/gethorizonprofile → POST /api/sightline/horizonprofile
- private/api/sightmap.py → API/Backend/Sightline/scripts/sightmap.py
- private/api/HorizonProfile.py → API/Backend/Sightline/scripts/HorizonProfile.py
- Extract validateMissionsPath into shared API/validateMissionsPath.js
- Update frontend calls.js and E2E tests to use new paths

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): update SPICE kernel relative path after script relocation

The script moved from private/api/ to API/Backend/Sightline/scripts/,
so the relative path to spice/kernels/ needs 4 parent traversals
instead of 2.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs(sightline): update help with detailed algorithm descriptions

- Document sightmap viewshed algorithm: source position via SPICE,
  DEM composite, tangent-plane projection, ray-march viewshed, output grid
- Document horizon profile algorithm: per-azimuth ray march, elevation
  angle tracking, curvature correction, distance recording
- Add parameter tables for both endpoints
- Document performance methods: log stepping, early termination,
  managed resolution, batch streaming, frame limits
- Update SPICE paths to reflect new spice/ directory
- Update visibility timeline description (now pixel-based)
- Add fog shading, hover tooltip, and range slider to horizon profile docs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): extract modules for Indicators, Export, Horizon, Visibility

- Extract SightlineTool_Indicators.js (~689 lines): Az/El canvas gauges, sky dome, mini RAE
- Extract SightlineTool_Export.js (~488 lines): PNG, GIF, CSV, Grid export functions
- Extract SightlineTool_Horizon.js (~574 lines): horizon profile canvas, fog shading, tooltip
- Extract SightlineTool_Visibility.js (~278 lines): visibility timeline bar chart
- Extract RangeSlider reusable component to src/design-system/components/RangeSlider/
- SightlineTool.js reduced from 3082 to 1919 lines (thin delegates to new modules)
- SightlineTool_Graphs.js reduced from 1532 to 981 lines (delegates to Horizon/Visibility)
- Fix shadowReach isFinite validation bug (Devin Review feedback)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): invert color ramp to black→white, add horizon polygon overlay

- Invert WhiteBlack color ramp to BlackWhite (black→white gradient)
- Add faint horizon polygon on 2D map showing the horizon profile outline
  - Updates whenever horizon profile is fetched/redrawn
  - Removed when sightline graphs panel is closed
  - Very faint styling: white outline 0.35 opacity, fill 0.06 opacity

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add horizon polygon toggle checkbox, increase polygon opacity

- Add 'Polygon:' checkbox in graphs header bar (right of horizon range slider)
- Default off; toggling on shows the horizon polygon overlay on the 2D map
- Polygon is more visible: outline 0.6 opacity, fill 0.12 opacity, weight 1.5
- Checkbox state resets to off when graph panel closes
- Uses existing mmgis-checkbox component pattern

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): thicker polygon border (weight 3), add tippy on Polygon label

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): composite hover uses bounds-based lookup instead of tile projection

The _onCompositeHover function was using Globe_.litho.projection tile
coordinates (topLeftTile, tileResolution) which don't exist in the
sightmap data model. Replaced with bounds-based row/col computation
using data._bounds [west, south, east, north] which is what the
sightmap API actually returns.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): update azimuth lines on map pan/zoom

Azimuth SVG overlay uses pixel coordinates from latLngToContainerPoint.
When the map pans, these coordinates become stale but the overlay wasn't
being redrawn. Now listens for 'move' events on the map while the graph
panel is open and redraws the source azimuth lines on every move.
Listener is removed on panel close.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): composite hover uses projBounds for projected CRS maps

When the map uses a custom projected CRS, Leaflet e.latlng coordinates
are in projected meters, not geographic degrees. The hover function now
uses data._projBounds (projected) when in a projected CRS and
data._bounds (geographic) when in standard geographic CRS.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): update polygon tippy text, add user-select:none to labels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): project mouse coords before comparing to projBounds

Leaflet's e.latlng gives geographic lat/lng even in projected CRS maps.
For projected CRS (lunar south pole stereographic), we need to convert
these to projected coordinates via crs.project() before comparing
against data._projBounds. This was causing the hover to always produce
out-of-range row/col on projected CRS maps.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightline): progressive log2 stepping in sightmap ray march

Replace fixed-step march with distance-based progressive stepping:
step = base * max(1, log2(r+1)), combined with margin-based acceleration.

Near the observer every pixel is sampled. At r=1000px the step grows
to ~10x base; at r=25000px to ~15x base. This dramatically reduces
iterations for high-resolution DEMs (e.g. 10m USGS) where rays can
span 25,000+ pixels. Expected ~4-8x fewer samples per ray at distance
with negligible accuracy loss (distant terrain must be very tall to
affect the elevation angle).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "perf(sightline): progressive log2 stepping in sightmap ray march"

This reverts commit 97d29eecbbf9c8e7ede869fa5e8bfa4745871ed0.

* Revert "Revert "perf(sightline): progressive log2 stepping in sightmap ray march""

This reverts commit 975c772101aa0ebb4d161b82156100d77596e348.

* perf(sightline): add in-march early termination + reduce working DEM size

Two optimizations on top of the progressive log2 stepping:

1. In-march early termination (#4): at each sample, checks if the
   best-case terrain angle (MAX_TERRAIN_H minus curvature drop) at the
   current distance is below the source elevation. If so, no further
   terrain can block the source and the ray stops immediately. Most
   effective for high-elevation sources.

2. Match working DEM to output resolution (#5): reduced working_dim
   from 2x output to 1x output. Rays march through a ~400px array
   instead of ~800px, halving samples per ray and DEM I/O. Shadow
   accuracy is preserved since the output grid resolution is unchanged.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add 2-minute timeout to sightmap.py

Uses signal.SIGALRM on Unix and a threading.Timer fallback on Windows.
On timeout, raises TimeoutError which is caught by the existing error
handler and returns a JSON error response. Timeout is cancelled on
successful completion.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): prevent server crash on oversized sightmap output

Add a 256 MB stdout buffer cap with try-catch around string
concatenation. If the Python process output exceeds the limit,
the child process is killed and a 413 error is returned instead
of crashing the Node.js server with RangeError: Invalid string length.

This can happen at native resolution on large DEMs (e.g. 30993x30993)
where the output grid JSON exceeds Node's string size limit.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): binary grid encoding, NDJSON streaming, delta compression, gzip

Implement 7 output size reduction optimizations:
1. Binary encoding: uint8 grid → zlib compress → base64 (gridB64z field)
2. RLE via delta: batch frames encoded as XOR diffs (deltaB64z field)
3. Frontend decode: DecompressionStream → Uint8Array → 2D grid
4. Delta reconstruction: XOR previous flat with decoded delta
5. Precision: uint8 (0/1/9) instead of JSON number arrays
6. gzip: Node route compresses single-frame JSON; batch streams through zlib.createGzip()
7. NDJSON streaming: batch mode streams one JSON line per frame via chunked transfer

Backend (sightmap.py):
- compute_sightmap() returns gridB64z instead of grid array
- compute_sightmap_batch() streams NDJSON lines to stdout
  First frame: full gridB64z, subsequent: deltaB64z (XOR vs prev)
- Helper functions _encode_grid() and _encode_grid_delta()

Node route (sightmap.js):
- Batch: pipes child stdout through optional gzip to response stream
- Single: buffers stdout, optionally gzip-compresses before sending
- Content-Type: application/x-ndjson for batch, application/json for single

Frontend (SightlineTool.js):
- _decodeGridB64z(): atob → DecompressionStream('deflate') → Uint8Array → 2D grid
- _applyDelta(): XOR reconstruct from previous frame flat array
- Batch: uses fetch() with ReadableStream to parse NDJSON line-by-line
- Progressive progress updates as NDJSON frames arrive during streaming

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): remove Python-side timeout, Node manages 3min single / scaled batch

Python no longer has an internal 120s timeout that would kill long batch jobs.
Node.js is the sole timeout authority:
- Single-frame: 3 minutes (180s)
- Batch: max(3min, frameCount × 30s), capped at 30 minutes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): cap batch timeout at 5 minutes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: fix stale comment — batch timeout cap is 5 min, not 30

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): replace CSV export with GeoTIFF for all modes

- Static: single-band uint8 GeoTIFF (0=shadow, 1=lit, 9=nodata)
- Playback: multi-band uint8 GeoTIFF (one band per frame)
- Composite: single-band float32 GeoTIFF (0.0-1.0 visibility fraction)

Uses the existing geotiff.js writeArrayBuffer with WGS 84 geographic CRS,
ModelTiepoint and ModelPixelScale from the sightmap bounds. Output is a
proper georeferenced TIFF openable in QGIS, ArcGIS, etc.

Removes the old CSV export which caused RangeError on large grids
(400x400 x 24 frames = 3.84M rows). A 24-frame multi-band GeoTIFF is
~4MB vs ~190MB CSV.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): fix GeoTIFF export — use correct writeArrayBuffer input format

Single-band (static/composite): pass flat TypedArray directly (not wrapped
in array) so geotiff.js uses the flat code path with height/width metadata.

Multi-band (playback): pass native 2D arrays [band][row][col] so geotiff.js
can read dimensions from array structure.

Verified all three modes produce valid GeoTIFFs via gdalinfo.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* revert(sightline): remove GeoTIFF exports, drop playback CSV export

GeoTIFF exports had issues (geotiff.js bugs with float32/custom CRS).
Reverts to original CSV exports for static and composite modes.
Removes the playback CSV export entirely (caused RangeError on large grids).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.1.2-20260615 [version bump]

* feat: Phase 1 plugin restructure — unified /plugins/ directory

- Move 16 tools from src/essence/Tools/ → plugins/core/tools/
- Move 13 backends from API/Backend/ → plugins/core/backend/
- Move 1 component from src/essence/Components/ → plugins/core/components/
- Add plugins/core/plugin.json manifest
- Implement discoverPluginsUnified() three-level hierarchy scanner
- Update updateTools.js, setups.js, resolve-plugin-deps.js to use unified scan
- Update tool/component config.json paths for new locations
- Add plugins/ to webpack babel-loader include
- Replace 6 gitignore patterns with /plugins/* and !/plugins/core/
- Update AGENTS.md, CONTRIBUTING.md for new plugin structure
- Update test helpers and specs for new layout

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct relative require paths in moved backends

Backend files moved from API/Backend/ to plugins/core/backend/ need
updated relative paths to reach API/ modules (logger, connection, etc).
Also fix JSDoc comment containing '*/' in glob pattern that broke parser.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.1.3-20260615 [version bump]

* fix: escape glob pattern in JSDoc comment that broke parser

The pattern 'plugins/*/tools/' in a block comment contains '*/' which
prematurely terminates the /* */ comment block.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct rootDir path depth and plugin.json version

- utils.js and sightmap.js rootDir needs 5 levels of ../ (not 4) to
  reach repo root from plugins/core/backend/X/routes/
- Sync plugin.json version with package.json (5.1.3-20260615)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.1.4-20260616 [version bump]

* fix: update relative import paths for plugin directory restructure

All tools and components moved from src/essence/Tools/ and
src/essence/Components/ to plugins/core/tools/ and
plugins/core/components/ in the Phase 1 restructure. This commit
updates ~250 relative import paths in those files so they resolve
correctly from the new directory depth.

Also fixes:
- mmgisAPI LegendTool import (now points to plugins/core/tools/Legend/)
- missionTemplates test require path (now plugins/core/backend/Utils/)
- setups.js and resolve-plugin-deps.js reverted to unified scan only

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct SPICE kernel path depth in sightmap.py and address review findings

- sightmap.py PATH_TO_KERNELS: needs 5 '../' levels from new location
  (plugins/core/backend/Sightline/scripts/ is 5 deep, was 4)
- plugin.json version: sync to 5.1.4-20260616 matching package.json
- New Tool Template: move to plugins/core/tools/ with updated imports

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(Phase 2): Standardize plugin manifests, split backend lifecycle, co-locate tests

- Rename config.json → plugin.json for all tool/component plugins
- Split backend setup.js into plugin.json (metadata) + plugin.js (lifecycle)
- Add Phase 2 manifest fields: uuid, id, version, type, tier, overridable, aliases, engines, peerDependencies
- Update pluginValidation.js with new field schema and type checks
- Enforce overridable:false in updateTools.js and setups.js
- Add engines.mmgis compatibility check using semver
- Implement semver-aware dependency conflict resolution (semver.intersects)
- Add peerDependencies validation via checkPeerDependencies()
- Simplify resolve-plugin-deps.js backend dep reading
- Move tool E2E tests into plugins/core/tools/X/tests/
- Move backend API tests into plugins/core/backend/X/tests/
- Update Playwright config to scan both tests/ and plugins/**/tests/
- Update CONTRIBUTING.md with Phase 2 manifest documentation
- Update test fixtures from config.json to plugin.json
- Add unit tests for Phase 2 validation, semver resolution, and peerDeps

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove /notes directory

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: overridable check inspects registered plugin, not incoming; fix config.json doc reference

- setups.js: Store setupManifests map to track already-registered plugin's
  manifest; check that when an override is attempted (matches updateTools.js
  pattern where registry[name].overridable is checked)
- CONTRIBUTING.md: Fix remaining config.json reference to plugin.json

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: Phase 3 — Plugin CLI and git-based registry system

- plugins/plugin-cli.js: CLI tool with list, install, remove, enable,
  disable, update, validate, deps, info, registry commands
- plugins/plugin-registries.json: git-based registry configuration
- plugins/plugin-state.json: enable/disable state (gitignored)
- Integrate plugin-state.json into discoverPluginsUnified() to skip
  disabled non-core plugins during discovery
- Core plugins are protected from removal and disabling
- npm run plugins maps to the CLI
- plugins/README.md: full user-facing documentation
- plugins/AGENTS.md: AI agent context for the plugin system
- CONTRIBUTING.md: added Plugin CLI section
- 17 unit tests for CLI commands and state integration

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: address Devin Review findings for Phase 3

- discoverPluginsUnified: validate plugin-state.json has 'plugins' key
  to prevent TypeError when state file is valid JSON but lacks the key
- plugins/README.md: fix tier values (core/community/private, not extended)
- plugins/README.md: display_name is not required for tools
- plugins/AGENTS.md: fix tools require name+paths, not display_name
- Add test for state file without plugins key

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: CONTRIBUTING.md template walkthrough references setup.js instead of plugin.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: CLI deps command uses claim.entry for pip/conda conflicts, not claim.version

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: add description to backend KNOWN_FIELDS for consistency

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mergeNpm picks highest-lower-bound range; docs mark type/version as Recommended

- mergeNpm now sorts compatible ranges by semver.minVersion() descending
  to pick the most restrictive range, not insertion-order last-seen
- AGENTS.md and README.m…
…inment (#1004)

* fix: pass through Python error messages to frontend on sightmap failure

When sightmap.py exits with code!=0, the Node handler was returning a
generic 'sightmap computation failed' message. Now it tries to parse
the structured JSON error from stdout first, so the actual error
(e.g. 'DEM is not a COG') reaches the frontend.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sanitize COG error message — no paths or tracebacks in response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove traceback from JSON error response, log to stderr only

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: show actual sightmap error message in toast

- calls.api error callback now parses JSON response body from jqXHR
- SightlineTool error handlers display server error message (e.g. COG)
- No traceback or paths in error responses (security)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: remove duplicate 'sightmap error' prefix from Python error messages

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): temporarily disable multiprocessing for batch mode

Run all timestamps sequentially in the main process so the DEM stays
in memory and avoids pickle serialization overhead per worker.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightmap): remove multiprocessing and Resolution UI option

- Remove all multiprocessing infrastructure (pool, workers, shared state)
- Batch timestamps run sequentially in the main process
- Remove Resolution dropdown from UI, default to ultra (800px static, 400px sweep)
- Always auto-regenerate on map move (no resolution-gated cutoff)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: temporarily disable Numba JIT for benchmarking comparison

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: re-enable Numba JIT after benchmarking (saves ~6s per frame)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* debug: re-add timing instrumentation to sightmap response

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: compute grid convergence analytically, skip GDAL TransformPoints

_compute_directions was calling _pixels_to_geo_batch on all 940K cells
(969x969 grid) to get each cell's longitude for the convergence angle.
This took ~1s (29% of total).

Now computes convergence directly from projected coordinates using
atan2(x - false_easting, sign*(y - false_northing)) — pure numpy
math, no GDAL coordinate transform. Expected: ~0.01s.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf: skip kernel unloading + increase coarse subgrid step to 50

- Remove spiceypy.unload() calls — process exits immediately after
  response so OS cleanup is sufficient. Saves ~0.235s.
- Increase COARSE_AZEL_STEP from 10 to 50 — reduces coarse subgrid
  points from ~9400 to ~400 for sun az/el interpolation. Sun position
  varies slowly across the DEM so fewer sample points suffice.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove timing debug instrumentation from sightmap.py

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: interpolate sun azimuth in sin/cos space to prevent wrap artifacts

When coarse subgrid has azimuth values near the 360°/0° boundary
(e.g. 355° and 5°), linear interpolation produces ~180° — completely
wrong direction that flips shadows. Now interpolates sin(az) and
cos(az) separately, then recovers the angle via atan2. This handles
the circular wrap correctly.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: viewport-aware sightmap — clip DEM read to visible area at native resolution

Frontend sends current map viewport bounds (projected coords) with each
static sightmap request. Backend clips the DEM read to the viewport
intersection, reading at native resolution (capped at maxOutputDim).

When zoomed in, this means the sightmap covers just the visible area
but at much higher resolution than the full-DEM downsampled version.
When zoomed out, viewport encompasses the full DEM and behavior is
unchanged.

For a 30993x30993 DEM zoomed to 1/10th coverage, instead of reading
the full raster decimated to 1600x1600, we read only ~3000x3000 native
pixels for the visible window — higher res and faster.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: compute viewport projected bounds from container corners, not lat/lng bbox

In polar stereographic CRS, the lat/lng bounding box from
getBounds() maps to a distorted region in projected space.
Now samples all 4 container pixel corners, projects each through
the CRS, and takes the envelope for correct projected bounds.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: position sightmap overlay using projected NW/SE corners directly

In polar/rotated CRS, L.latLngBounds normalises by min/max lat/lng,
which shuffles corners — getNorthWest() and getSouthEast() return
points that don't correspond to the projected rectangle's NW and SE.
The overlay is then mispositioned and stretched.

New _projImageOverlay() helper overrides the overlay's _reset method
to compute pixel position from the projected NW (xmin, ymax) and
SE (xmax, ymin) corners via latLngToLayerPoint, bypassing the
normalisation entirely.  Applied to all three overlay creation paths:
static sightmap, heatmap, and sweep frame.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: override _animateZoom on projected overlay to prevent zoom jump

The default _animateZoom reads the normalised L.latLngBounds which
has wrong corners in polar CRS, causing the overlay to briefly jump
to the top-left during zoom transitions.  Now _animateZoom uses the
projected NW corner via _latLngToNewLayerPoint for correct animation.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: wire viewport bounds through batch/sweep mode

Frontend sweep call now sends viewportBounds.  Backend
compute_sightmap_batch accepts and passes viewport_bounds
to open_dem so playback also clips to the visible DEM region
at native resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: equalize sweep/static resolution + show 'Sweeping' on progress button

- _resolutionToMaxDim now returns 800 for both modes (was 400 for sweep)
- ProgressButton label shows children text alongside percentage
- SightlineElement shows 'Sweeping'/'Generating' while loading

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: actually commit _resolutionToMaxDim change to equalize sweep/static res

Was missed from the previous commit — sweep was still getting 400 instead of 800.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: re-fetch horizon profile after pan so charts stay in sync

When the user panned, invalidateHorizonCache set _horizonCache to null
but never triggered a re-fetch.  Subsequent scrub or playback frame
changes found the cache empty and either skipped the horizon redraw or
drew the visibility timeline with no profile (marking everything not
visible).

- _onPanEnd now calls invalidateAndRefetch() which re-fetches the
  horizon profile if the graph panel is open.
- _scrubToFrame and updatePlaybackFrame fall back to fetchAndDrawHorizon
  when the cache is null instead of drawing with missing data.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile azimuth for projected CRS (polar stereo)

HorizonProfile.py was marching along pixel/raster-space directions,
treating pixel-up as north.  In polar stereographic the grid north
axis is rotated from true north by the grid convergence angle, so the
profile azimuths were offset and the terrain silhouette appeared
rotated in the chart.

Added _grid_convergence() which computes the convergence at the
observer's projected coordinates via atan2(x - FE, -(y - FN)).  Each
geographic azimuth is now rotated by the convergence before marching
in pixel space, making the profile azimuths true geographic azimuths.
No change for geographic (unprojected) CRS.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct horizon profile convergence formula for polar stereo

Two bugs in the previous convergence fix:

1. _grid_convergence used atan2(x, -y) unconditionally, but for
   south-pole stereo the sign should be +1 (north is away from
   pole = positive y). Now uses north_sign = -1 for north-pole,
   +1 for south-pole, matching sightmap.py exactly.

2. The convergence was subtracted (geo_az - convergence) when it
   should be added (geo_az + convergence), matching sightmap.py's
   convention where convergence rotates from grid north to true
   north.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: extract rate limiters into shared scripts/rateLimiters.js module

- Create scripts/rateLimiters.js exporting apilimiter, authLimiter, computeLimiter
- Remove inline rateLimit() definitions from scripts/server.js
- Remove authLimiter/computeLimiter from the 's' setup object
- Update Users/routes/users.js: import authLimiter directly, replace late-binding wrapper
- Update Users/setup.js: remove router._authLimiter assignment
- Update Utils/routes/utils.js: import computeLimiter directly, replace all 8 late-binding wrappers
- Update Utils/setup.js: remove router._computeLimiter assignment
- Update unit tests to import from the shared module

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.0.26-20260609 [version bump]

* fix: use geodesic destination for azimuth lines instead of angle rotation

Replace the _localNorthAngle + screen-space rotation approach with a
direct forward geodesic method: compute a destination lat/lng 1° along
the desired azimuth, project it through Leaflet, and draw the line.

This avoids potential compound angle errors and works correctly for
any CRS because it uses Leaflet's own projection pipeline end-to-end
rather than computing a screen-space north-offset angle and rotating.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: convert reference mission DEMs to Cloud Optimized GeoTIFF (COG)

Both the Earth (USGS SF Hill) and Lunar South Pole (LRO LOLA 4000m)
DEMs are now tiled COGs with deflate compression and overviews.
This ensures consistent behavior with the sightmap COG requirement
and enables fast overview-based reads at any resolution.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add lunar LSMT to chronice, fix TimeUI indicator cleanup and observer time sync

- chronice.py: add lunar LSMT support using SPICE et2lst with observer
  longitude; format: LDAY-NNNNNLHH:MM:SS; reverse conversion via iterative
  refinement
- utils.js: pass optional lng param through to chronice.py
- SightlineTool.js: remove TimeUI indicator on mode switch, cancel sweep,
  resweep start, and pan-end; pass lng from observer point for LSMT observers
- SightlineElement.jsx: update global TimeControl when observer time inputs
  are changed (blur/Enter), fixing Mars SOL time not updating the TimeUI
- Lunar ref mission config: add Moon (LSMT) observer with type=lsmt

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: LSMT lng fallback to map center, hide empty DEM dropdown, Enter key on observer time

- _getObserverLng: fall back to map center when indicatorLastDragPoint is null
- SightlineElement: hide DEM dropdown when no data options configured
- SightlineElement: add onKeyDown Enter handler on observer time inputs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Improve Mars Reference Mission

* chore: bump version to 5.0.28-20260610 [version bump]

* fix: sightmap overlay CRS mismatch for non-custom projections, restore config descriptions

- Only use _projImageOverlay and viewport clipping when the mission uses
  a custom projected CRS (projection.custom=true). For standard longlat/
  Mercator missions (like Mars), the DEM's projected bounds are in a
  different CRS than the map, causing misplaced overlays.
- Restore Layer-specific DEMs config row and improve field descriptions
  in sightline tool config.json (lost during ShadeTool→SightlineTool rename).
- Add sweepColorRamps and observer type examples to descriptionFull.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: restore sightline config descriptions from ShadeTool, remove data row, clear default name

- Remove Layer-specific DEMs config row (previously asked to remove)
- Restore detailed descriptions from old ShadeTool config:
  - Sources: documents name/value properties, dropdown usage, kernel path
  - Observers: documents name/value/frame/body, chronos setup path
  - Default Height: full description of height parameter behavior
  - Observer Time Placeholder: documents format string usage
  - Frame/Body fields: proper SPICE reference descriptions
- Remove 'Sightline N' default element name (now empty)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: observer time input 7hr drift — chronice result parsed as local instead of UTC

Root cause: chronice lmst→utc returns '2026-05-30T21:36:57.975' (no Z suffix).
The old ShadeTool correctly did: result.replace(' ', 'T') + 'Z'
The new code used a regex chain that failed when milliseconds were present
without a trailing Z, leaving the string timezone-ambiguous. new Date()
then parsed it as local time (UTC-7), adding ~7 hours.

Fix: strip milliseconds then unconditionally append Z, matching the old
ShadeTool approach. The /ZZ$/ → Z guard prevents double-Z if chronice
ever returns a Z-suffixed result in the future.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sightmap sun direction for cylindrical projections, 1s time drift, tab-switch regen, add editable time field

1. Sightmap sun direction: _compute_directions now only applies convergence
   rotation for azimuthal projections (stereo/gnomonic). For cylindrical
   projections (Equidistant Cylindrical, Mercator), grid north = geographic
   north so convergence = 0. Previously applied polar-stereo formula to all
   projected CRS, giving ~90deg rotation on Mars DEM.

2. Observer time 1-second drift: restored _lastConvertedMs pattern from old
   ShadeTool. Saves sub-second precision from observer->UTC conversion and
   re-attaches it in UTC->observer reverse conversion for exact round-trips.

3. Tab-switch regeneration: _onTimeChange now tracks _lastGeneratedTime and
   skips if unchanged, preventing redundant sightmap computation when
   TimeControl re-broadcasts the same time on tab refocus.

4. Editable time field (vstOptionTime): restored from old ShadeTool. Shows
   current end time in configured format (DOY, etc), editable on blur/Enter.
   Parses via utcTimeFormat if configured, else appends Z directly.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: match old ShadeTool vstOptionTime styling and DOY format

- CSS matches old ShadeTool exactly: full-width centered input, bold 14px,
  color-p0 bg, color-a1-5 text, transparent border that shows color-c on focus
- Clock icon positioned absolute right (pointer-events: none) as in original
- Structure uses flexbetween wrapper matching old jQuery markup
- Mars reference mission utcTimeFormat changed to DOY: '%Y-%j %H:%M:%S'
  giving output like '2026-150 21:36:57' instead of ISO format

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* ui: hide observer start time in static mode, show only 'Time' field

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: horizon profile rotation for cylindrical CRS + visibility chart text non-selectable

1. HorizonProfile.py _grid_convergence: same fix as sightmap.py — only
   apply convergence for azimuthal projections (stereo/gnomonic). For
   cylindrical projections (Mars Equidist. Cylindrical), convergence = 0,
   so horizon terrain profile azimuths are now correct.

2. Visibility chart (.sightlineVisWrap): added user-select: none so
   dragging the timeline scrubber doesn't accidentally highlight text.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: horizon profile aspect ratio for geographic CRS + crosshair styling/lag

- HorizonProfile.py: compute per-axis pixel scales (px_scale_x, px_scale_y)
  so the march direction accounts for longitude compression at observer
  latitude. For geographic CRS at 38°N, 1° lon ≈ 0.79 × 1° lat in meters;
  without this the march traces wrong physical angles, distorting azimuths.
  Also computes correct per-step physical distance instead of using the
  averaged pixel_scale.

- Crosshair restyled: smaller (8px circle, 5px arms), lime green with
  black borders (box-shadow outline).

- Crosshair converted from raw DOM element to Leaflet DivIcon marker.
  Leaflet handles positioning in its own transform pipeline, eliminating
  the lag that occurred when updating CSS left/top on the move event.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add lime center dot at visible map center while sightline tool is open

Small 6px lime green dot with 1px black border, always at 50%/50% of
the map container (CSS-only positioning, no event tracking needed).
Added on make(), removed on destroy().

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: update crosshair position immediately when sweepCenter is set

Previously the crosshair only corrected its position on the next pan
event. Now _updateCrosshairPosition() is called right after sweepCenter
is stored for both static sightmap and batch/sweep completion.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: sightmap route security + batch limits + test fix (Devin Review)

- Add SAFE_NAME_RE validation on target, obsRefFrame, obsBody to prevent
  directory traversal via SPICE kernel paths (matches /ll2aerll_bulk).
- Add MAX_TIMES=200 cap on sightmap batch to prevent resource exhaustion.
- Fix E2E test: batch response is a raw JSON array, not { results: [...] }.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: add error handler + headersSent guards on sightmap spawn

Match ll2aerll_bulk pattern: handle child.on('error') and
child.stdin.on('error') to prevent hung responses if Python
fails to start. Add !res.headersSent guards on all response
paths in the close handler.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: get_pixel_scale uses actual array rows instead of ds.RasterYSize

After open_dem decimates a large DEM, gt[5] is scaled but ds still has
the original RasterYSize. Using ds.RasterYSize with the decimated gt
produces a wrong mid_lat for geographic CRS pixel scale. Now accepts
dem_rows directly from dem.shape.

Also: encodeURIComponent the chronice lng argument to match the other
CLI args (consistency with unquote() on the Python side).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct East vector cross product order in sun_azel_at_cell

cross(normal, north) yields West, not East. Changed to
cross(north, normal) to match the batch version _sun_azel_batch.
Currently unused at runtime but prevents future bugs.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove dead code from sightmap.py

Removed unused scalar functions that were superseded by vectorized
equivalents: sun_azel_at_cell (replaced by _sun_azel_batch),
is_nodata (replaced by _vectorized_is_nodata), geo_to_pixel (never
called). Also removed the unused ds parameter from open_dem return
value and _compute_bounds signature — ds was only kept alive for
get_pixel_scale which no longer needs it after the dem_rows fix.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: remove dead code from SightlineTool frontend

SightlineTool.js: removed showSightlinemapLayers, showSweepLayers,
refreshAllHeatmaps, _nextPow2 — all defined but never called.

SightlineTool_Algorithm.js: removed the entire old client-side
sightline algorithm (sightline, processUp/Down, mask, curveData,
isNoData, compositeResults, calcHeight*, initializeGrids, perOctant)
and their unused imports (jquery, F_, L_, G_). Only
cumulativeVisibility is called externally; all other methods were
from the pre-backend era and superseded by sightmap.py.

SightlineTool_Graphs.js: removed _localNorthAngle, replaced by
the geodesic _destinationPoint + _azimuthEndpoint approach.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: color picker/ramp dropdown clipping + reorder default colors

Remove overflow:hidden from vstSightlineItem, vstSweepCard, and
vstSweepCardsSection so absolutely-positioned color picker palettes
and color ramp dropdowns are no longer clipped by their parent
containers. Add border-radius to headers directly to preserve
rounded corners.

Bump vstColorPalette z-index from 100 to 10000 to match the
ColorRampPicker popup z-index.

Reorder MULTI_SOURCE_COLORS: yellow -> blue -> red -> green
(swapped blue and red positions).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: improve LSMT inverse conversion precision from ~30s to <1s

et2lst returns integer (hr, mn, sc) so one lunar second spans ~29 ET
seconds. The old iterative loop converged to ±1 lunar second, giving
~30s UTC precision. Now uses binary search after the coarse loop to
find the exact ET boundary where the second ticks over, narrowing to
<0.5 ET seconds. Result is placed at the midpoint of the lunar
second window for minimal round-trip error.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: color picker palette uses fixed positioning to escape overflow

The Collapsible panel has overflow:hidden for its open/close
animation, which clips the color picker dropdown. Changed the
palette to position:fixed, computed from the swatch's bounding
rect on click, so it escapes all overflow containers.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: revert fixed positioning, use overflow:visible on open panels

Reverts position:fixed approach. Instead overrides overflow to
visible on open Collapsible panels inside sightlineTool via
[data-open] selector, so the color palette can extend past the
panel boundary while keeping overflow:hidden during animations.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): implement 6 improvements to Sightline tool

1. Combine Playback and Composite Sweeps: always build both heatmap
   and atlas after sweep; mode switching is instantaneous without
   re-running the sweep.

2. Min/Max Distance options: add UI inputs and pass minDistance/
   maxDistance through to sightmap.py ray-march kernel and
   HorizonProfile.py. Includes curvature-based early termination.

3. Better Color Ramps + Fix Transparency Bug: expose sweepColorRamps
   in admin config, reorder defaults, fix evalColor/evalColorWithStops
   discrete bin index calculation (Math.floor -> Math.round).

4. Draggable Horizon Profile Point: crosshair marker is now interactive
   and draggable; on dragend updates indicatorLastDragPoint and refetches
   horizon profile at the new location.

5. Vis Chart - Remove Gradients: remove gradient transition logic from
   drawVisibilityTimeline; uncertain regions shown as occluded.

6. Document Algorithms: add detailed algorithm documentation to
   sightmap.py and HorizonProfile.py headers.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.1.1-20260611 [version bump]

* fix: resolve merge conflict in configure/package.json

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): set mode before sweepShowAllFrames, add distance to horizon cache key

- switchElementMode now sets sightlineMode='playback' before calling
  sweepShowAllFrames, which filters by sightlineMode.
- Horizon profile cache now includes maxDist/minDist so changing distance
  parameters invalidates the cache and triggers a re-fetch.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): address 4 review issues

1. Default color ramps: add inferno + viridis to front of defaults;
   remove sweepColorRamps from all reference mission configs so they
   use the built-in defaults.

2. Mode switching: keep Results section open when switching between
   composite/playback (don't collapse if sweep data exists); call
   sweepShowFrame directly for the element when switching to playback.

3. Crosshair dragging: use Leaflet.Editable (enableEdit/disableEdit)
   instead of L.marker draggable option; listen on 'editable:dragend'.
   Map click handler now only triggers static sightline when element
   is in static mode - prevents unwanted resweep in playback mode.

4. maxDistance fix: backend route (utils.js) was not passing
   minDistance/maxDistance through to the Python sightmap.py stdin
   payload. Added both fields to payloadObj.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): hardcode 6 color ramps, revert crosshair to non-draggable

1. Color ramps: remove custom/configurable sweepColorRamps entirely.
   Hardcode exactly 6 ramps:
   - [transparent, color]
   - [transparent, color, transparent]
   - Inferno
   - Viridis
   - Red → Green (RdYlGn)
   - Black → White (Greys)

2. Crosshair: revert to original non-draggable behavior (interactive: false).
   Remove indicatorLastDragPoint from store and all references.
   Horizon profile and visibility chart now always use the current map
   center — they auto-update on pan end via the existing
   invalidateAndRefetch() call in _onPanEnd.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): reverse B&W ramp to White→Black, single color stop

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): anchor horizon profile to sweep center when available

Revert the horizon profile to use sweepCenter when a sweep exists,
falling back to the current map center when no sweep has been run.
This keeps the horizon chart, entity arcs, and visibility timeline
all consistent with the sweep observer location.

Skip horizon invalidation on pan when anchored to sweep center
to avoid unnecessary backend calls.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightline): add timing instrumentation to sightmap pipeline

Python backend (sightmap.py):
  - Timing for: kernel loading, SPICE az/el, DEM open, grid precompute,
    per-frame sun grid + march + tolist, json.dumps, total
  - Batch response now returns {results, _timing} with per-frame arrays
  - DEM/output dimensions logged for context

Node.js route (utils.js):
  - Log total spawn-to-close time, JSON parse time, stdout size

Frontend (SightlineTool.js):
  - Log API round-trip, grid parsing, heatmap compute, atlas build,
    total frontend processing
  - Console output tagged [Sightmap Timing] / [Sightmap Sweep]

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* TEMP: strip grid data from sightmap response for timing debug

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): cache frame-invariant data in batch mode

Pre-compute and cache across all frames:
- Coarse grid lat/lng (coordinate transform done once, not per-frame)
- Bilinear interpolation weights (indices + weight arrays)
- Ellipsoid geometry for az/el (cell positions, normals, north/east vectors)
- Grid convergence angle for azimuthal projections

Per-frame now only computes:
- Source direction vector subtraction on ~119 coarse points
- 3x bilinear weight-apply (fast matmul, no index recomputation)
- sin/cos for direction on full grid

Expected speedup: source_grid from ~116ms/frame to ~15-25ms/frame
(~5-8x faster for 145 frames: ~17s → ~2-4s)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightmap): eliminate full-grid trig via angle addition formula

Instead of: bilinear interp sin/cos → arctan2 → +convergence → radians → sin/cos
Now:        bilinear interp sin/cos → multiply by cached sin/cos(convergence)

Removes 6 numpy operations on the full 240K-cell grid per frame
(arctan2, where, add, radians, sin, cos) and replaces them with
4 multiply + 2 add operations (cheaper element-wise math).

Also computes coarse az as normalized sin/cos components directly
from the dot products, skipping degrees/radians conversions on
the coarse grid too.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add resolution dropdown + restore grid data

Add viewport-relative resolution control (1×, 0.5×, 0.25×, 0.125×)
to the Display section. Default is 0.25× (medium). The scale factor
multiplies the viewport's longest pixel dimension to produce
maxOutputDim sent to the backend.

- 1× = native (no decimation beyond viewport crop)
- 0.5× = half viewport dims
- 0.25× = quarter (default, balanced speed/quality)
- 0.125× = eighth (fastest, coarsest)

Server-side clamp raised from 800 to 4096 to support 1× on large
viewports.

Also restores grid data in sightmap responses (reverts the TEMP
debug strip).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightmap): scale frame limit by resolution

1× (maxDim≥800): 256 frames
0.5× (maxDim≥400): 512 frames
0.25× (maxDim≥200): 1024 frames
0.125× (maxDim<200): 2048 frames

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightmap): replace times array with startTime/endTime/stepSeconds

Send 3 scalar values instead of potentially 2000+ timestamp strings.
Backend generates timestamps internally from the range.

- Frontend sends ISO timestamps (e.g. '2026-06-11T19:53:00Z') and
  stepSeconds (e.g. 60)
- Node.js route validates the range and computes frame count for
  limit checking
- Python parses ISO start/end, generates time strings with timedelta
- Frame limit raised to 2048 max (at 0.125× resolution)
- Shrinks request payload from ~100KB to ~500B for large sweeps

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): range circles, max distance infinity toggle, viewport padding

- Draw dashed min/max distance circles on map when values are non-zero
  (orange for min, blue for max). Circles update on value change and
  center on sweep observer position when available.

- Add ∞ toggle button on Max Dist row. When active, sends maxDistance=-1
  to backend which skips viewport cropping and reads the full DEM (at
  the appropriate overview level for the resolution setting).

- Add Tooltip on Max Dist label explaining viewport-only vs full DEM
  behavior.

- Backend: when maxDistance > 0, pad viewport bounds by that distance
  in all directions so shadow-casting terrain beyond the visible extent
  is included. When maxDistance=-1 (infinity), viewport_bounds is set
  to None so the full DEM is read.

- Store: add maxDistInfinity per-element field (default false).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: range circles use projected polygon, infinity mode preserves viewport resolution

Range circles:
- Replace L.circle (broken on custom planetary CRS) with L.polygon
  built from 64 vertices computed in projected coordinates via
  crs.project/unproject. Works correctly on polar stereo and any
  custom L.Proj.CRS.

Infinity mode resolution fix:
- Separate DEM read extent from output grid extent. When infinity
  mode is active (maxDistance=-1), the full DEM is loaded at overview
  resolution for shadow tracing, but the output grid is sized to the
  viewport subset only — preserving the same cell density as normal
  mode.
- When maxDistance > 0 (finite padding), DEM read bounds are padded
  by maxDistance in all directions. Output grid still viewport-only.
- Both compute_sightmap and compute_sightmap_batch support
  output_offset_row/col parameters that offset output grid cells
  within the loaded DEM. Coarse subgrid, precompute, and batch
  context all respect the offset so rays trace through the full
  loaded DEM while only evaluating viewport cells.
- Add _compute_bounds_from_proj() to compute geographic bounds from
  projected viewport coords for correct response bounds.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: replace min/max distance fields with DEM Extent dropdown

Remove minDistance, maxDistance, maxDistInfinity fields and range
circles. Replace with a simple 'DEM Extent' dropdown (Viewport / Full
DEM) in the Display section, defaulting to Viewport.

- Viewport: uses only the DEM visible on screen (fast)
- Full DEM: reads the entire raster for distant shadow casting (slower,
  sends maxDistance=-1 to backend)

Removes ~120 lines of range circle rendering code that is no longer
needed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "refactor: replace min/max distance fields with DEM Extent dropdown"

This reverts commit b3d6cc503a170a522f48bdd7f5b21d723b9612e7.

* Revert "fix: range circles use projected polygon, infinity mode preserves viewport resolution"

This reverts commit 6b265aa823e9634e13d5857119826b174360d384.

* Revert "feat(sightline): range circles, max distance infinity toggle, viewport padding"

This reverts commit fd0c5c2262d9c2bccffa69102709be759333c1ad.

* feat(sightline): add Shadow Reach LOD field for distant shadow casting

Add a 'Shadow Reach' input (km) to the Display section that extends
terrain loaded for shadow computation beyond the visible viewport.
When set, the backend reads the viewport DEM at full resolution and
a padded border region at a coarser COG overview level, composites
them into a single array, and marches rays through the full composite
while outputting only the viewport portion.

- New open_dem_composite() reads viewport + low-res border
- Output grid offset support in _precompute_grid_arrays,
  _compute_sun_grid, _precompute_batch_grid_context
- Replaces minDistance/maxDistance with single shadowReach parameter
- Tooltip explains the viewport-extension concept
- Default 0 = viewport only (no performance impact)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): prevent OOM on large shadow reach, clamp to curvature, blur-only regen

- Redesigned open_dem_composite(): single DEM read of the padded
  region at a managed resolution (scales working_dim by viewport/pad
  ratio, capped at 4× max_working_dim) instead of building a massive
  array at viewport pixel scale then upsampling.
- Shadow reach clamped to planetary curvature limit: sqrt(2*R*h_max)
  where h_max=10km. For Moon (R=1737km) this caps at ~186km.
- Shadow Reach input only triggers sightmap regen on blur (unfocus),
  not on every keystroke.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): Enter key triggers regen on Shadow Reach input

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): respect drag order for sightmap z-index, fix drop indicator position

- After rendering a sightmap overlay, re-apply z-order based on the
  current element order in the store so the panel ordering is respected
  regardless of which sightmap finishes loading first.
- Fix drop indicator: changed border-bottom to border-top so the line
  appears above the target element (matching where the drop will place
  the dragged item).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): position-aware drag-and-drop indicator (above/below)

The drop indicator now shows border-top when hovering the upper half
of a target element (insert above) and border-bottom when hovering
the lower half (insert below). The drop logic uses the cursor's Y
position relative to the element midpoint to determine placement.

Applied to both SightlinePanel element cards and SweepSection cards.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): visibility timeline uses sightmap grid pixels, not horizon profile

- Visibility chart now checks the observer's pixel in each frame's
  sightmap grid (lit=1/2 → visible, shadow=0 → occluded) instead of
  comparing source az/el against the horizon profile.
- Observer pixel computed from projected coordinates (using CRS
  projection) when available, falling back to geographic bounds.
- Replaces horizon profile interpolation in drawVisibilityTimeline
  with direct grid pixel lookup via centerVisible.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore(sightline): remove timing logs and fix graph slider performance

- Remove all timing instrumentation from sightmap.py (timing dict,
  perf_counter calls, stderr serialization log, import time)
- Remove _timing from sightmap API responses (single + batch)
- Remove all console.log timing calls from SightlineTool.js
- Fix graph time slider performance: _scrubToFrame was redrawing
  horizon + visibility canvases synchronously AND triggering the
  same redraws again via the scrub callback (sweepShowAllFrames →
  updatePlaybackFrame → requestAnimationFrame). Now _scrubToFrame
  just sets the index and calls the callback, letting
  updatePlaybackFrame handle all redraws in a single rAF.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add distance-based fog shading to horizon profile

Backend (HorizonProfile.py):
- Track distance (meters) to the horizon point at each azimuth
- Output now includes [az, el, dist_m] per sample

Frontend (_drawHorizonCanvas):
- Parse distance from profile data (backward-compatible if missing)
- Replace uniform terrain fill with per-strip vertical fills
- Each strip's opacity mapped via log scale from distance:
  close horizon = opaque, far horizon = transparent
- Falls back to uniform fill if no distance data available

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase horizon profile fog opacity variation

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): horizon profile now refreshes after new sweep completes

updatePlaybackFrame was using _horizonCache directly without validating
it against the current sweep center. After a new sweep set a different
sweepCenter, the stale cached profile was still drawn. Now routes through
fetchAndDrawHorizon which validates cache keys (lat/lng/height) and
refetches when the center has changed.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add hover tooltip to horizon profile showing azimuth, elevation, and distance

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase horizon profile max radius from 5km to 50km

5km was too short to reach far crater rims on coarser DEMs, causing
the horizon profile to report deeply negative elevation angles where
the ray never found the actual skyline.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase horizon profile max radius to 250km

Also raised the backend cap from 100km to 500km to accommodate.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightline): add log stepping + early termination to horizon profile

Ray march now uses logarithmic step size (1px near, ~11px at 2500px)
so distant terrain is sampled more coarsely. Early termination stops a
ray once the maximum plausible terrain peak (10km, minus curvature)
at the current distance can't beat the already-found max elevation
angle. Together these reduce samples from ~2500/ray to ~200-600/ray
for 250km radius at 100m/pixel.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add header with dual-handle log-scale horizon distance slider

Adds a 'Sightline Graphs' title and a dual-handle range slider to the
bottom panel header. The slider controls min/max horizon lookup distance
on a log scale (1m–250km). Dragging either handle invalidates the cache
and refetches the horizon profile with the new range. Default: 100m–250km.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): default horizon range to 1m–250km and add tippy tooltip

Changed min distance default from 100m to 1m. Added tippy tooltip on
the 'Horizon:' label explaining the dual-handle slider controls.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): increase crosshair circle and center dot radius by 1px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): deduplicate visibility chart time ticks and add user-select:none

When numTicks > frame count, multiple tick positions could round to the
same frame index, producing duplicate labels (e.g. two 'Jan 14 12:00').
Now skips any tick whose frameIdx matches the previous one. Also added
user-select: none to the time labels row.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): align visibility ticks with red slider position

- Cap numTicks to frame count (no more ticks than frames)
- Position ticks using frameIdx/(frameCount-1) — same formula as
  the red time slider — so they always line up exactly

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): set vstTimeStep width to 76px

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): extract Sightline into dedicated backend module

Move sightmap and horizon profile endpoints from API/Backend/Utils/ into
a new API/Backend/Sightline/ module with its own setup.js, routes, and
scripts directory.

- POST /api/utils/sightmap → POST /api/sightline/sightmap
- POST /api/utils/gethorizonprofile → POST /api/sightline/horizonprofile
- private/api/sightmap.py → API/Backend/Sightline/scripts/sightmap.py
- private/api/HorizonProfile.py → API/Backend/Sightline/scripts/HorizonProfile.py
- Extract validateMissionsPath into shared API/validateMissionsPath.js
- Update frontend calls.js and E2E tests to use new paths

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): update SPICE kernel relative path after script relocation

The script moved from private/api/ to API/Backend/Sightline/scripts/,
so the relative path to spice/kernels/ needs 4 parent traversals
instead of 2.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs(sightline): update help with detailed algorithm descriptions

- Document sightmap viewshed algorithm: source position via SPICE,
  DEM composite, tangent-plane projection, ray-march viewshed, output grid
- Document horizon profile algorithm: per-azimuth ray march, elevation
  angle tracking, curvature correction, distance recording
- Add parameter tables for both endpoints
- Document performance methods: log stepping, early termination,
  managed resolution, batch streaming, frame limits
- Update SPICE paths to reflect new spice/ directory
- Update visibility timeline description (now pixel-based)
- Add fog shading, hover tooltip, and range slider to horizon profile docs

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor(sightline): extract modules for Indicators, Export, Horizon, Visibility

- Extract SightlineTool_Indicators.js (~689 lines): Az/El canvas gauges, sky dome, mini RAE
- Extract SightlineTool_Export.js (~488 lines): PNG, GIF, CSV, Grid export functions
- Extract SightlineTool_Horizon.js (~574 lines): horizon profile canvas, fog shading, tooltip
- Extract SightlineTool_Visibility.js (~278 lines): visibility timeline bar chart
- Extract RangeSlider reusable component to src/design-system/components/RangeSlider/
- SightlineTool.js reduced from 3082 to 1919 lines (thin delegates to new modules)
- SightlineTool_Graphs.js reduced from 1532 to 981 lines (delegates to Horizon/Visibility)
- Fix shadowReach isFinite validation bug (Devin Review feedback)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): invert color ramp to black→white, add horizon polygon overlay

- Invert WhiteBlack color ramp to BlackWhite (black→white gradient)
- Add faint horizon polygon on 2D map showing the horizon profile outline
  - Updates whenever horizon profile is fetched/redrawn
  - Removed when sightline graphs panel is closed
  - Very faint styling: white outline 0.35 opacity, fill 0.06 opacity

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add horizon polygon toggle checkbox, increase polygon opacity

- Add 'Polygon:' checkbox in graphs header bar (right of horizon range slider)
- Default off; toggling on shows the horizon polygon overlay on the 2D map
- Polygon is more visible: outline 0.6 opacity, fill 0.12 opacity, weight 1.5
- Checkbox state resets to off when graph panel closes
- Uses existing mmgis-checkbox component pattern

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): thicker polygon border (weight 3), add tippy on Polygon label

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): composite hover uses bounds-based lookup instead of tile projection

The _onCompositeHover function was using Globe_.litho.projection tile
coordinates (topLeftTile, tileResolution) which don't exist in the
sightmap data model. Replaced with bounds-based row/col computation
using data._bounds [west, south, east, north] which is what the
sightmap API actually returns.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): update azimuth lines on map pan/zoom

Azimuth SVG overlay uses pixel coordinates from latLngToContainerPoint.
When the map pans, these coordinates become stale but the overlay wasn't
being redrawn. Now listens for 'move' events on the map while the graph
panel is open and redraws the source azimuth lines on every move.
Listener is removed on panel close.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): composite hover uses projBounds for projected CRS maps

When the map uses a custom projected CRS, Leaflet e.latlng coordinates
are in projected meters, not geographic degrees. The hover function now
uses data._projBounds (projected) when in a projected CRS and
data._bounds (geographic) when in standard geographic CRS.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): update polygon tippy text, add user-select:none to labels

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): project mouse coords before comparing to projBounds

Leaflet's e.latlng gives geographic lat/lng even in projected CRS maps.
For projected CRS (lunar south pole stereographic), we need to convert
these to projected coordinates via crs.project() before comparing
against data._projBounds. This was causing the hover to always produce
out-of-range row/col on projected CRS maps.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* perf(sightline): progressive log2 stepping in sightmap ray march

Replace fixed-step march with distance-based progressive stepping:
step = base * max(1, log2(r+1)), combined with margin-based acceleration.

Near the observer every pixel is sampled. At r=1000px the step grows
to ~10x base; at r=25000px to ~15x base. This dramatically reduces
iterations for high-resolution DEMs (e.g. 10m USGS) where rays can
span 25,000+ pixels. Expected ~4-8x fewer samples per ray at distance
with negligible accuracy loss (distant terrain must be very tall to
affect the elevation angle).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* Revert "perf(sightline): progressive log2 stepping in sightmap ray march"

This reverts commit 97d29eecbbf9c8e7ede869fa5e8bfa4745871ed0.

* Revert "Revert "perf(sightline): progressive log2 stepping in sightmap ray march""

This reverts commit 975c772101aa0ebb4d161b82156100d77596e348.

* perf(sightline): add in-march early termination + reduce working DEM size

Two optimizations on top of the progressive log2 stepping:

1. In-march early termination (#4): at each sample, checks if the
   best-case terrain angle (MAX_TERRAIN_H minus curvature drop) at the
   current distance is below the source elevation. If so, no further
   terrain can block the source and the ray stops immediately. Most
   effective for high-elevation sources.

2. Match working DEM to output resolution (#5): reduced working_dim
   from 2x output to 1x output. Rays march through a ~400px array
   instead of ~800px, halving samples per ray and DEM I/O. Shadow
   accuracy is preserved since the output grid resolution is unchanged.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): add 2-minute timeout to sightmap.py

Uses signal.SIGALRM on Unix and a threading.Timer fallback on Windows.
On timeout, raises TimeoutError which is caught by the existing error
handler and returns a JSON error response. Timeout is cancelled on
successful completion.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): prevent server crash on oversized sightmap output

Add a 256 MB stdout buffer cap with try-catch around string
concatenation. If the Python process output exceeds the limit,
the child process is killed and a 413 error is returned instead
of crashing the Node.js server with RangeError: Invalid string length.

This can happen at native resolution on large DEMs (e.g. 30993x30993)
where the output grid JSON exceeds Node's string size limit.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): binary grid encoding, NDJSON streaming, delta compression, gzip

Implement 7 output size reduction optimizations:
1. Binary encoding: uint8 grid → zlib compress → base64 (gridB64z field)
2. RLE via delta: batch frames encoded as XOR diffs (deltaB64z field)
3. Frontend decode: DecompressionStream → Uint8Array → 2D grid
4. Delta reconstruction: XOR previous flat with decoded delta
5. Precision: uint8 (0/1/9) instead of JSON number arrays
6. gzip: Node route compresses single-frame JSON; batch streams through zlib.createGzip()
7. NDJSON streaming: batch mode streams one JSON line per frame via chunked transfer

Backend (sightmap.py):
- compute_sightmap() returns gridB64z instead of grid array
- compute_sightmap_batch() streams NDJSON lines to stdout
  First frame: full gridB64z, subsequent: deltaB64z (XOR vs prev)
- Helper functions _encode_grid() and _encode_grid_delta()

Node route (sightmap.js):
- Batch: pipes child stdout through optional gzip to response stream
- Single: buffers stdout, optionally gzip-compresses before sending
- Content-Type: application/x-ndjson for batch, application/json for single

Frontend (SightlineTool.js):
- _decodeGridB64z(): atob → DecompressionStream('deflate') → Uint8Array → 2D grid
- _applyDelta(): XOR reconstruct from previous frame flat array
- Batch: uses fetch() with ReadableStream to parse NDJSON line-by-line
- Progressive progress updates as NDJSON frames arrive during streaming

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): remove Python-side timeout, Node manages 3min single / scaled batch

Python no longer has an internal 120s timeout that would kill long batch jobs.
Node.js is the sole timeout authority:
- Single-frame: 3 minutes (180s)
- Batch: max(3min, frameCount × 30s), capped at 30 minutes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): cap batch timeout at 5 minutes

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* docs: fix stale comment — batch timeout cap is 5 min, not 30

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(sightline): replace CSV export with GeoTIFF for all modes

- Static: single-band uint8 GeoTIFF (0=shadow, 1=lit, 9=nodata)
- Playback: multi-band uint8 GeoTIFF (one band per frame)
- Composite: single-band float32 GeoTIFF (0.0-1.0 visibility fraction)

Uses the existing geotiff.js writeArrayBuffer with WGS 84 geographic CRS,
ModelTiepoint and ModelPixelScale from the sightmap bounds. Output is a
proper georeferenced TIFF openable in QGIS, ArcGIS, etc.

Removes the old CSV export which caused RangeError on large grids
(400x400 x 24 frames = 3.84M rows). A 24-frame multi-band GeoTIFF is
~4MB vs ~190MB CSV.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix(sightline): fix GeoTIFF export — use correct writeArrayBuffer input format

Single-band (static/composite): pass flat TypedArray directly (not wrapped
in array) so geotiff.js uses the flat code path with height/width metadata.

Multi-band (playback): pass native 2D arrays [band][row][col] so geotiff.js
can read dimensions from array structure.

Verified all three modes produce valid GeoTIFFs via gdalinfo.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* revert(sightline): remove GeoTIFF exports, drop playback CSV export

GeoTIFF exports had issues (geotiff.js bugs with float32/custom CRS).
Reverts to original CSV exports for static and composite modes.
Removes the playback CSV export entirely (caused RangeError on large grids).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.1.2-20260615 [version bump]

* feat: Phase 1 plugin restructure — unified /plugins/ directory

- Move 16 tools from src/essence/Tools/ → plugins/core/tools/
- Move 13 backends from API/Backend/ → plugins/core/backend/
- Move 1 component from src/essence/Components/ → plugins/core/components/
- Add plugins/core/plugin.json manifest
- Implement discoverPluginsUnified() three-level hierarchy scanner
- Update updateTools.js, setups.js, resolve-plugin-deps.js to use unified scan
- Update tool/component config.json paths for new locations
- Add plugins/ to webpack babel-loader include
- Replace 6 gitignore patterns with /plugins/* and !/plugins/core/
- Update AGENTS.md, CONTRIBUTING.md for new plugin structure
- Update test helpers and specs for new layout

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct relative require paths in moved backends

Backend files moved from API/Backend/ to plugins/core/backend/ need
updated relative paths to reach API/ modules (logger, connection, etc).
Also fix JSDoc comment containing '*/' in glob pattern that broke parser.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.1.3-20260615 [version bump]

* fix: escape glob pattern in JSDoc comment that broke parser

The pattern 'plugins/*/tools/' in a block comment contains '*/' which
prematurely terminates the /* */ comment block.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct rootDir path depth and plugin.json version

- utils.js and sightmap.js rootDir needs 5 levels of ../ (not 4) to
  reach repo root from plugins/core/backend/X/routes/
- Sync plugin.json version with package.json (5.1.3-20260615)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: bump version to 5.1.4-20260616 [version bump]

* fix: update relative import paths for plugin directory restructure

All tools and components moved from src/essence/Tools/ and
src/essence/Components/ to plugins/core/tools/ and
plugins/core/components/ in the Phase 1 restructure. This commit
updates ~250 relative import paths in those files so they resolve
correctly from the new directory depth.

Also fixes:
- mmgisAPI LegendTool import (now points to plugins/core/tools/Legend/)
- missionTemplates test require path (now plugins/core/backend/Utils/)
- setups.js and resolve-plugin-deps.js reverted to unified scan only

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: correct SPICE kernel path depth in sightmap.py and address review findings

- sightmap.py PATH_TO_KERNELS: needs 5 '../' levels from new location
  (plugins/core/backend/Sightline/scripts/ is 5 deep, was 4)
- plugin.json version: sync to 5.1.4-20260616 matching package.json
- New Tool Template: move to plugins/core/tools/ with updated imports

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat(Phase 2): Standardize plugin manifests, split backend lifecycle, co-locate tests

- Rename config.json → plugin.json for all tool/component plugins
- Split backend setup.js into plugin.json (metadata) + plugin.js (lifecycle)
- Add Phase 2 manifest fields: uuid, id, version, type, tier, overridable, aliases, engines, peerDependencies
- Update pluginValidation.js with new field schema and type checks
- Enforce overridable:false in updateTools.js and setups.js
- Add engines.mmgis compatibility check using semver
- Implement semver-aware dependency conflict resolution (semver.intersects)
- Add peerDependencies validation via checkPeerDependencies()
- Simplify resolve-plugin-deps.js backend dep reading
- Move tool E2E tests into plugins/core/tools/X/tests/
- Move backend API tests into plugins/core/backend/X/tests/
- Update Playwright config to scan both tests/ and plugins/**/tests/
- Update CONTRIBUTING.md with Phase 2 manifest documentation
- Update test fixtures from config.json to plugin.json
- Add unit tests for Phase 2 validation, semver resolution, and peerDeps

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* chore: remove /notes directory

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: overridable check inspects registered plugin, not incoming; fix config.json doc reference

- setups.js: Store setupManifests map to track already-registered plugin's
  manifest; check that when an override is attempted (matches updateTools.js
  pattern where registry[name].overridable is checked)
- CONTRIBUTING.md: Fix remaining config.json reference to plugin.json

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: Phase 3 — Plugin CLI and git-based registry system

- plugins/plugin-cli.js: CLI tool with list, install, remove, enable,
  disable, update, validate, deps, info, registry commands
- plugins/plugin-registries.json: git-based registry configuration
- plugins/plugin-state.json: enable/disable state (gitignored)
- Integrate plugin-state.json into discoverPluginsUnified() to skip
  disabled non-core plugins during discovery
- Core plugins are protected from removal and disabling
- npm run plugins maps to the CLI
- plugins/README.md: full user-facing documentation
- plugins/AGENTS.md: AI agent context for the plugin system
- CONTRIBUTING.md: added Plugin CLI section
- 17 unit tests for CLI commands and state integration

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: address Devin Review findings for Phase 3

- discoverPluginsUnified: validate plugin-state.json has 'plugins' key
  to prevent TypeError when state file is valid JSON but lacks the key
- plugins/README.md: fix tier values (core/community/private, not extended)
- plugins/README.md: display_name is not required for tools
- plugins/AGENTS.md: fix tools require name+paths, not display_name
- Add test for state file without plugins key

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: CONTRIBUTING.md template walkthrough references setup.js instead of plugin.js

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: CLI deps command uses claim.entry for pip/conda conflicts, not claim.version

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: add description to backend KNOWN_FIELDS for consistency

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: mergeNpm picks highest-lower-bound range; docs mark type/version as Recommended

- mergeNpm now sorts compatible ranges by semver.minVersion() descending
  to pick the most restrictive range, not insertion-order last-seen
- AGENTS.md and README.md updated to mark type and version as Recommended
  to match actual validation behavior (optional-but-typed)

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: support "version": "core" — auto-resolves to MMGIS version

Core plugins now use "version": "core" in their plugin.json manifests.
The CLI resolves this to the current MMGIS version from package.json
when displaying plugin info (e.g. "5.1.4-20260616 (core)").

This eliminates the need to manually sync plugin versions with MMGIS
releases — core plugin versions are always the MMGIS version by
definition.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add author, license, repository, keywords fields to plugin manifest

- Added to COMMON_FIELDS in pluginValidation.js (no warning for these)
- All 31 core plugin.json files: author=NASA-AMMOS/MMGIS,
  license=Apache-2.0, repository=https://github.com/NASA-AMMOS/MMGIS
- CLI info command displays author, license, repository, keywords
- README.md manifest table updated with new fields

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: CLI visual overhaul + fix Devin Review bugs

CLI visual improvements (zero dependencies):
- ANSI colors throughout (cyan plugin names, yellow versions,
  green/red status, dim metadata, bold headers)
- Box-drawing layout for 'info' command
- Column-aligned output for 'list' command with summary bar
- Step indicators [1/3] for install/remove/update
- Colored help with version display
- --no-color flag (also respects NO_COLOR env per no-color.org)
- --json flag for machine-readable output (list, info)

Bug fixes (from Devin Review):
- checkPeerDependencies now resolves "version": "core" to the
  actual MMGIS version before semver checks (was silently skipped)
- cmdDeps now includes conda dependencies alongside pip

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: revert info to flat layout, add type headers to list

- info command: back to simple labeled rows with colors (no box-drawing)
- list command: group plugins under type sub-headers (Tools, Backend,
  Components) with per-type colors (cyan, magenta, blue)
- Update test assertions for new output format

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: use high-contrast colors for dark/light terminal support

Replace blue (34) and magenta (35) which are hard to read on dark
backgrounds. New palette uses only high-contrast ANSI colors:
- Tools: cyan, Backend: yellow, Components: green
- Repository URLs: cyan instead of blue
- Registry URLs: white instead of blue

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add webpack aliases for plugin-friendly imports

Add 5 webpack resolve aliases so tools/components can import shared
modules without fragile relative paths:
  @basics  → src/essence/Basics/
  @essence → src/essence/
  @design  → src/design-system/
  @pre     → src/pre/
  @external → src/external/

Before: import L_ from '../../../../src/essence/Basics/Layers_/Layers_'
After:  import L_ from '@basics/Layers_/Layers_'

Also adds jsconfig.json for IDE go-to-definition support.
Refactors all 233 deep relative imports across 49 tool/component files.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: local install copies by default, --link for symlink

Local path installs now copy the directory instead of symlinking.
This avoids Windows EPERM errors with symlinks.

  npm run plugins -- install /path/to/plugins      # copies
  npm run plugins -- install --link /path/to/plugins  # symlink

With --link, if symlink fails (EPERM on Windows), falls back to
junction automatically.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: consolidate plugin docs, remove legacy templates

Merge plugins/AGENTS.md into plugins/README.md with full plugin
templates (tool, backend, component). Remove:
- API/Backend/setupTemplate.js (template now in README)
- plugins/AGENTS.md (merged into README)
- plugins/core/tools/New Tool Template.js (template now in README)

Add deprecation warnings in discoverPluginsUnified() when plugins
have legacy config.json or setup.js instead of plugin.json/plugin.js.

Update CONTRIBUTING.md, docs, and .knowledge to reference new locations.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: rename discoverPluginsUnified to discoverPlugins, improve warnings

Remove legacy two-level discoverPlugins() (no callers remain). Rename
discoverPluginsUnified() to discoverPlugins() across all callers, tests,
and docs.

Add deprecation warnings to CLI discoverAll() for config.json/setup.js.
Add flat-repo structure warning during install when no tools/backend/
components subdirectory is found.

Fix stale references: updateTools.js Kinds error message (config.js ->
plugin.json), Development.md paths (API/Backend -> plugins/core/backend,
setup.js -> plugin.js).

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* feat: add activate command, auto-activate on plugin changes, fix backend engines check

Add 'activate' CLI command that regenerates src/pre/tools.js and
src/pre/components.js without a full webpack build. Auto-called after
install, remove, enable, disable, and update commands.

Add engines.mmgis compatibility check to API/setups.js for backend
plugins, matching the existing check in updateTools.js for tools
and components.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* refactor: activate prints diff only, suppresses verbose logger output

Instead of dumping every loaded tool/component, activate() now
compares src/pre/tools.js and src/pre/components.js before and
after regeneration, printing only added/removed entries. Logger
console output is suppressed during the regeneration step.

Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>

* fix: color-code 'no changes' in activate based on context

Yellow when ch…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants