diff --git a/EXPLORER_STATE.md b/EXPLORER_STATE.md
index ebd6b39..177df95 100644
--- a/EXPLORER_STATE.md
+++ b/EXPLORER_STATE.md
@@ -13,7 +13,15 @@ DOM checkboxes is the source of truth for facet state inside SQL builders;
the URL is the source of truth across reloads.
All file:line references below are against `explorer.qmd` at commit
-`94e7674` (the tree this doc is being written against).
+`94e7674` (the tree this doc was originally written against). The
+inventory has had targeted edits as later changes land — most notably
+the **mockup-v1 PR** ([#200](https://github.com/isamplesorg/isamplesorg.github.io/issues/200))
+which removed the Globe/Table view toggle, relocated search into an
+in-map overlay, added a sidebar search input that mirrors the in-map
+one, and made the samples table a permanent surface below the globe.
+See §6 "Mockup-v1 addendum" for the full delta. Line numbers may not
+match exactly; the contract is the source of truth, not the line
+citations.
---
@@ -21,13 +29,14 @@ All file:line references below are against `explorer.qmd` at commit
| field | owner | default | URL repr | hydration site | write-back trigger | validation | notes |
|-------|-------|---------|----------|----------------|--------------------|------------|-------|
-| `search` | DOM `#sampleSearch` value | omitted | raw string | `applyQueryToSearch()` at start of `phase1` (`:937`) | `writeQueryState()` (`:445-446`) called from `doSearch()` (`:1789`) and from globe/table toggle | trim only; min 2 chars enforced at search time, not in URL | written even on no-result searches |
+| `search` | DOM `#sampleSearch` value (mirrored to `#sampleSearchSidebar`) | omitted | raw string | `applyQueryToSearch()` at start of `phase1` (hydrates both inputs) | `writeQueryState()` called from `doSearch()` | trim only; min 2 chars enforced at search time, not in URL | written even on no-result searches |
| `sources` | DOM `#sourceFilter` checkboxes | omitted (= all 4 checked) | CSV of `SOURCE_VALUES` ∩ user-checked | `applyQueryToSourceFilter()` at start of `phase1` (`:938`) | `writeQueryState()` from source filter `change` (`:1620`) | filtered by `SOURCE_VALUES` allowlist (`:407`); param removed when all 4 checked (`:449`) | empty (zero checked) renders as `&sources=` and yields `1=0` predicate (`:379`) |
| `material` | DOM `#materialFilterBody` checkboxes | omitted (= no filter) | CSV of full URIs | `applyQueryToFacetFilters()` at end of `facetFilters` (`:1061`) | `writeQueryState()` from `handleFacetFilterChange` (`:1642`) | none — checkbox `value` already constrained by render | empty checked set ⇒ param removed (`:459`) |
| `context` | DOM `#contextFilterBody` checkboxes | omitted | CSV of full URIs | same as `material` | same as `material` | none | same |
| `object_type` | DOM `#objectTypeFilterBody` checkboxes | omitted | CSV of full URIs | same as `material` | same as `material` | none | same |
-| `view` | `body.classList.contains('table-view-active')` | omitted (= globe) | `table` only; absent ⇒ globe | `tableView` cell reads `params.get('view')` (`:1239-1240`) and calls `setView(...)` with `updateUrl=false` | `writeQueryState()` from `setView(mode, true)` via globe/table button clicks (`:1217-1218`, `:1210`) | only `table` is honored (`:1240`) | `writeQueryState()` reads body class, not `viewer.*` |
-| `page` | inner closure `let page = 0` in `tableView` (`:1080`) | not in URL | — | — | resets to 0 on `refreshTable()` (`:1163`); ±1 on prev/next (`:1219-1220`) | clamped to `[0, totalPages-1]` (`:1112`) | **#163 item 6** — table page is intentionally not URL state today; if/when added, must coexist with the cross-filter contract below |
+| ~~`view`~~ | _removed in mockup-v1 (#200)_ | — | — | — | — | — | The Globe/Table toggle is gone — the samples table is now permanent below the globe. `writeQueryState()` does `params.delete('view')` to canonicalize legacy bookmarks. See §6 "Mockup-v1 addendum" |
+| `search_scope` | local closure `_searchScope` in `zoomWatcher` | omitted (= `world`) | `area` only; absent ⇒ world | `_searchScope` hydrated at top of `zoomWatcher` from `params.get('search_scope')` | `persistSearchScope()` from `doSearch()` and button clicks | exact match `'area'` | sidebar `#sampleSearchSidebar` Enter always submits `world`, never `area` — see §6 mockup-v1 addendum |
+| `page` | inner closure `let page = 0` in `tableView` | not in URL | — | — | resets to 0 on `refreshTable()`; ±1 on prev/next | clamped to `[0, totalPages-1]` | **#163 item 6** — table page is intentionally not URL state today; if/when added, must coexist with the cross-filter contract below |
| `perf` | — (read-only feature flag) | omitted | `1` to enable | `perfPanel` cell reads (`:1921-1922`) | never written | `=== '1'` exact match | never round-tripped; safe to add other tail params |
### Quarto site-search collision
@@ -80,17 +89,21 @@ predates the store-on-`viewer` pattern and isn't worth migrating.
| location | role | written by | read by | notes |
|----------|------|------------|---------|-------|
-| `document.body.classList['table-view-active']` | view marker (table vs globe) | `setView()` in `tableView` (`:1198`) | `writeQueryState()` (`:462`); `isTableViewActive()` (`:755`) | URL `view` param is **derived** from this class, not the other way around |
-| `data-facet`, `data-value` on `.facet-row` and `.facet-count` | facet selectors for in-place count mutation | rendered in `renderFilter` (`:1053`) and the static source legend (`:270-273`) | `applyFacetCounts()` (`:553-565`) | rebuilding the HTML would lose mid-interaction selections; `data-*` attrs are why we mutate counts in place |
-| `data-lat`, `data-lng`, `data-pid` on `.sample-row` | click-to-fly payload for search/nearby results | rendered in `doSearch()` (`:1822`) and `updateSamples()` not currently using data-* (only the search list does) | search-row click handler (`:1832-1845`) | the nearby-samples list does not have data-* today; click-to-fly only works from the search list |
+| ~~`document.body.classList['table-view-active']`~~ | _removed in mockup-v1 (#200)_ | — | — | The view marker class is gone with the Globe/Table toggle. The samples table is permanent below the globe; `isTableViewActive()` and `setView()` were deleted |
+| `data-facet`, `data-value` on `.facet-row` and `.facet-count` | facet selectors for in-place count mutation | rendered in `renderFilter` and the static source legend | `applyFacetCounts()` | rebuilding the HTML would lose mid-interaction selections; `data-*` attrs are why we mutate counts in place |
+| `data-lat`, `data-lng`, `data-pid` on `.sample-row` | click-to-fly payload for search/nearby results | rendered in `doSearch()` | search-row click handler | the nearby-samples list does not have data-* today; click-to-fly only works from the search list |
+| `data-pid` on `.samples-table tbody tr` | click-to-select payload for the permanent table | rendered in `tableView` `renderTable()` | table-row click handler (same ceremony as the search-row click — see §6 mockup-v1 addendum) | per-PID lookup uses `rowsByPid: Map` cached at `refreshTable()` time; not the DOM |
+| `tr.selected` on `.samples-table` | "this row is the current sample selection" visual marker | table-row click handler (`add`); next click on a different row (`remove`); next `renderTable()` reflects current `viewer._globeState.selectedPid` | CSS only | derived; do not read. The globe → table direction is *not* live (only repaints on the next refresh) |
| `.recomputing` on `.facet-count` | transient "loading" styling during cross-filter recompute | `markFacetCountsRecomputing()` (`:570`) | `applyFacetCounts()` clears it (`:562`) | UI-only; not state in the persistence sense |
| `.zero` on `.facet-row` | "value has zero count under current filters" styling | `applyFacetCounts()` (`:565`) | CSS only | derived; do not read |
| `.disabled` on `#sourceFilter .legend-item` | unchecked source visual | `updateSourceLegendState()` (`:395-400`) | CSS only | derived from checkbox `checked`; do not read |
-DOM input elements (the four facet checkbox bodies + `#sampleSearch` + `#maxSamples`)
-are the **source of truth** for `getActiveSources()`, `getCheckedValues()`,
-`getTableMaxSamples()`, and the search input. SQL builders read these
-directly each call.
+DOM input elements (the four facet checkbox bodies + `#sampleSearch` +
+`#sampleSearchSidebar` + `#maxSamples`) are the **source of truth** for
+`getActiveSources()`, `getCheckedValues()`, `getTableMaxSamples()`, and
+the search input. SQL builders read `#sampleSearch` directly each call.
+`#sampleSearchSidebar` is kept in lock-step with `#sampleSearch` via a
+two-way `input`-event mirror (see §6 mockup-v1 addendum).
---
@@ -125,8 +138,9 @@ This invariant exists because there's no central "selection store" — selection
Grep for `_urlParamsHydrated` in `explorer.qmd` returns no hits. The flag from
PR #159's first cut was removed; the new contract is "URL → DOM hydration runs
exactly once per cell that owns the corresponding DOM, gated only by the OJS
-DAG (phase1 ⇒ source/search hydration; facetFilters ⇒ facet hydration;
-tableView ⇒ view hydration)."
+DAG (phase1 ⇒ source/search hydration; facetFilters ⇒ facet hydration).
+Mockup-v1 removed the `tableView ⇒ view` hydration step along with the
+`?view=` URL param."
---
@@ -142,7 +156,7 @@ db [DuckDBClient.of()]
viewer [creates Cesium viewer; reads readHash() once]
└── phase1 [needs viewer + db; runs URL→DOM hydration for search + sources]
└── facetFilters [needs phase1; loads vocab + summaries; sets _baselineCounts; runs URL→DOM hydration for facet checkboxes]
- ├── tableView [needs facetFilters; reads view from URL once]
+ ├── tableView [needs facetFilters; calls refreshTable() unconditionally on boot — no URL hydration]
└── zoomWatcher [needs facetFilters; registers ALL change handlers; runs deep-link pid restore]
└── perfPanel [opt-in; needs phase1; renders perf panel if ?perf=1]
```
@@ -155,16 +169,17 @@ viewer [creates Cesium viewer; reads readHash() once]
| `viewer` | `:773` | — | `#cesiumContainer` mounts globe | `scene.postRender`×2; mouse-move; left-click | `pushState` (sample/cluster click) |
| `phase1` | `:930` | `viewer`, `db` | `#sourceFilter` (via hydration); stats DOM | — | — |
| `facetFilters` | `:979` | `phase1`, `db` | `#materialFilterBody`, `#contextFilterBody`, `#objectTypeFilterBody`; facet count text | — | — |
-| `tableView` | `:1071` | `facetFilters` | `#globe-layout`, `#tableContainer`, `#tableControls`, `#samplesTable`, `body.classList` | globe/table buttons; prev/next; max input; **change** on all four facet bodies | `replaceState` via `writeQueryState` (`view` flip) |
-| `zoomWatcher` | `:1246` | `phase1`, `facetFilters`, `db` | facet count text; stats; phase msg; sample card; samples list | source filter `change`; material/context/object_type `change`; `camera.changed`; `window` `hashchange`; share button; search button; search input keydown | `pushState` and `replaceState` via `buildHash` (camera, mode flip, sample fly); `replaceState` via `writeQueryState` (filter changes, search submit, share) |
+| `tableView` | `:1071` | `facetFilters` | `#tableContainer`, `#samplesTable`, `tr.selected` class | prev/next; max input; **change** on all four facet bodies; table-row clicks | `replaceState` via `buildHash` from table-row click (sets `#pid` directly, mirrors sample-mode globe click) |
+| `zoomWatcher` | `:1246` | `phase1`, `facetFilters`, `db` | facet count text; stats; phase msg; sample card; samples list | source filter `change`; material/context/object_type `change`; `camera.changed`; `camera.moveEnd` (sub-threshold pan settle, #205); `window` `hashchange`; share button; search button; in-map search input keydown; sidebar search input `input` (mirror) and keydown (world-scope submit) | `pushState` and `replaceState` via `buildHash` (camera changed/moveEnd, mode flip, sample fly, **share button**); `replaceState` via `writeQueryState` (filter changes, search submit) |
| `perfPanel` | `:1910` | `phase1` | `#perfPanel` floating div | close button | — |
Note that **two cells register `change` listeners on the four facet container
-elements**: `tableView` (`:1233-1236`) marks the table dirty and refreshes if
-visible, and `zoomWatcher` (`:1617`, `:1649-1651`) reloads the globe and
-debounces the cross-filter count refresh. Both listeners fire on every facet
-change; this is intentional (each cell handles its own concerns) but is the
-single most "magical" coupling in the file — touch with care.
+elements**: `tableView` calls `refreshTable()` unconditionally (the table
+is permanent post-mockup-v1, so there's no view gate); `zoomWatcher`
+reloads the globe and debounces the cross-filter count refresh. Both
+listeners fire on every facet change; this is intentional (each cell
+handles its own concerns) but is the single most "magical" coupling in
+the file — touch with care.
---
@@ -336,6 +351,109 @@ A future Heavy revisit may rethink (A) global-filter semantics if usage
data shows users *expect* the map and facets to update with search.
That decision is deferred until #170-#172 land.
+### Mockup-v1 addendum: in-map overlay, sidebar mirror, permanent table ([#200](https://github.com/isamplesorg/isamplesorg.github.io/issues/200), 2026-05)
+
+A coordinated UI refactor aligning the explorer with Hana's wireframe.
+Built as a single PR sequenced into independently revertable commits.
+All changes are UI-relocation / surface-additive; the data/query
+contract is unchanged from the (C) decision + #178 light path above.
+
+**M-1A — Search controls relocated into an in-map overlay.** The
+search input, the two scope buttons (Search Selected Areas / Search
+Entire World), help text, and `#searchResults` count moved from a
+top-of-page `.explorer-controls` block into `.map-search-overlay`,
+absolutely positioned over `#cesiumContainer` inside a new `.map-wrap`
+wrapper. All element IDs preserved so existing handlers in `zoomWatcher`
+and the `writeQueryState()` contract bind unchanged. Overlay
+positioning clears the Cesium toolbar column (`left: 50px`) and the
+base-layer picker dropdown wins z-stack (`z-index: 1100` vs overlay's
+`1000`).
+
+**M-1B — Sidebar open-text search input that mirrors the in-map one.**
+A second input `#sampleSearchSidebar` lives at the top of `.side-panel`.
+Two-way `input`-event mirror keeps both inputs in lock-step as a single
+logical query term:
+
+- Typing in `#sampleSearchSidebar` propagates to `#sampleSearch`.
+- Typing in `#sampleSearch` propagates to `#sampleSearchSidebar`.
+- Mirror handlers guard against feedback loops by comparing values
+ before assignment; programmatic `.value =` does not fire `input`,
+ so the comparison is sufficient (no debounce flag).
+- `applyQueryToSearch()` hydrates *both* inputs from the `?search=`
+ URL param.
+- `writeQueryState()` still reads from `#sampleSearch` only — mirror
+ parity makes the choice arbitrary.
+
+**Sidebar Enter = world scope (Option B).** Enter on
+`#sampleSearchSidebar` always calls `doSearch('world')`, regardless of
+the in-map two-button scope choice. Rationale: typed-text-from-sidebar
+implies "find anywhere"; the in-map buttons remain the explicit way to
+constrain to current viewport. Both Enter handlers gate on
+`!e.isComposing && e.keyCode !== 229` so IMEs that emit Enter to
+commit a candidate don't submit on the pre-commit value.
+
+**M-2 — Display-only color legend at bottom-center of the map.**
+`.map-color-legend` is a static `aria-hidden="true"` swatch row with
+`pointer-events: none`. It mirrors the four-source palette from
+`assets/js/source-palette.js` and never affects filter state — the
+functional toggles remain in `#sourceFilter` in the sidebar. Sits
+above Cesium's bottom-left credits via `bottom: 30px`.
+
+**M-5 — Permanent samples table; Globe/Table toggle removed.**
+The biggest delta to this contract:
+
+- `#globeViewBtn`, `#tableViewBtn`, `body.table-view-active`,
+ `isTableViewActive()`, and the `?view=` URL param are all **gone**.
+- The samples table is always visible below the globe. Map height
+ shrinks to `clamp(400px, 50vh, 540px)` (was 500/65vh/680) so the
+ table fits below without a full-page-height feel.
+- `tableView` cell initializes by calling `refreshTable()`
+ unconditionally on boot.
+- `writeQueryState()` does `params.delete('view')` to canonicalize
+ legacy bookmarked `?view=table&...` URLs. Caveat: only the next
+ `writeQueryState()` call strips the param — hash-only writes via
+ `buildHash(viewer)` (camera moves, sample/cluster click, Share)
+ preserve `location.search` as-is, so `?view=` lingers until the
+ user touches a filter, the search box, or anything else that
+ flows through `writeQueryState()`.
+- **Table-row click = sample-mode globe click.** Clicking a row in
+ `.samples-table tbody tr[data-pid]` reuses the same async-selection
+ ceremony as the search-row click handler (and the on-globe
+ sample-point click), with one table-specific addition: the direct
+ hash write. Preconditions before any work: bail if
+ `e.target.tagName === 'A'` (let inline source-link clicks through),
+ bail if the row has no resolvable sample / no lat-lng, bail if
+ `typeof viewer === 'undefined'`. Once preconditions pass:
+ 1. `const isStale = freshSelectionToken(viewer)` — bump BEFORE any await.
+ 2. `viewer._globeState.selectedPid` set; `selectedH3` cleared.
+ 3. `updateSampleCard({...})` populates the sidebar.
+ 4. `viewer.camera.flyTo({...})` at altitude `50000`.
+ 5. **Table-specific:** `history.replaceState(null, '', buildHash(viewer))`
+ writes the `#pid` hash directly. The search-row and globe-point
+ paths do not need this — they rely on `zoomWatcher`'s camera
+ listener to fold selection into the hash. The table-row path
+ can fire at very-early-boot before `zoomWatcher` is wired and
+ while `_suppressHashWrite` is still `true`, so it writes the
+ hash itself.
+ 6. Repaint `.selected`: remove from any prior `tr.selected`, add to
+ the clicked row — synchronously, before the async detail query.
+ 7. Lazy-load `description` from `wide_url` with the pid SQL-escaped
+ via `pid.replace(/'/g, "''")`; gated on `if (isStale()) return`
+ before any DOM/state mutation. The error branch also stale-checks.
+ A `rowsByPid: Map` cached at `refreshTable()` time gives O(1)
+ per-PID lookup on click.
+- **Asymmetric selection sync.** Clicking a table row updates the
+ globe + sidebar + URL. Clicking a globe point or a search-result
+ row does NOT live-update the table's `.selected` class — the table
+ only repaints `.selected` on the next `renderTable()`. Treated as
+ acceptable scope for v1; bidirectional highlight is a follow-up.
+
+**State surfaces added by mockup-v1.** See the table additions in §1
+(URL params: `search_scope`; legacy `view` struck), §3 (DOM-as-state:
+`tr.selected`, `data-pid` on table rows; `body.table-view-active`
+struck), and §5 (OJS cell graph: `tableView` no longer writes `view`
+to URL; `tableView` does write `#pid` to hash via `buildHash`).
+
---
## 7. Facet-count contract
@@ -347,7 +465,7 @@ The cross-filter rule (codified by Codex in #158, restated here):
| other facet selections | **YES** | counts answer "if I add this value, how many samples would match all OTHER active filters plus this one"; that's the drill-out signal users want |
| viewport (camera bounds) | **NO** | counts are global. Viewport-scoped counts would couple facet UI to camera state, contradict the "facets describe the dataset" reading, and require re-querying on every camera change |
| `?search=` text query | **NO** | option (C); search renders a side panel + result-pin overlay, but does not alter facet counts |
-| view mode (globe vs table) | **NO** | the same dataset underlies both; facet counts should not flip when the user toggles view |
+| ~~view mode (globe vs table)~~ | _moot — mockup-v1 (#200) removed the Globe/Table toggle; both surfaces are permanent_ | — |
Exposed via `applyFacetCounts(facetKey, countsMap)` (`:551-567`):
diff --git a/explorer.qmd b/explorer.qmd
index 78fbcf3..cb6e3ac 100644
--- a/explorer.qmd
+++ b/explorer.qmd
@@ -22,7 +22,9 @@ format: