## Summary
`BackfillPathJSONAsync` re-selected observations whose `path_json` was
already `'[]'`, rewrote them to `'[]'`, and looped forever. The
`len(batch) == 0` exit condition was never reached, the migration marker
was never recorded, and the ingestor sustained 2–3 MB/s WAL writes at
idle (76% of CPU in `sqlite.Exec` per pprof).
## Fix
Drop `'[]'` from the WHERE clause:
```diff
WHERE o.raw_hex IS NOT NULL AND o.raw_hex != ''
- AND (o.path_json IS NULL OR o.path_json = '' OR o.path_json = '[]')
+ AND (o.path_json IS NULL OR o.path_json = '')
```
`'[]'` is the "already attempted, no hops" sentinel (still written at
line 994 of `cmd/ingestor/db.go` when `DecodePathFromRawHex` returns no
hops). Excluding it from the WHERE lets the loop terminate after one
full pass and the migration marker `backfill_path_json_from_raw_hex_v1`
to be recorded.
## TDD
- **Red commit** (`19f8004`):
`TestBackfillPathJSONAsync_BracketRowsTerminate` — seeds 100
observations with `path_json='[]'` and a `raw_hex` that decodes to zero
hops, asserts the migration marker is written within 5s. Fails on master
with *"backfill never recorded migration marker within 5s — infinite
loop on path_json='[]' rows"*.
- **Green commit** (`7019100`): WHERE-clause fix + updates
`TestBackfillPathJsonFromRawHex` row 1 expectation (the pre-seeded
`'[]'` row is now correctly skipped instead of being re-decoded).
## Test results
```
ok github.com/corescope/ingestor 49.656s
```
## Acceptance criteria from #1119
- [x] Backfill terminates within 1 polling cycle of having no progress
to make
- [x] Migration marker `backfill_path_json_from_raw_hex_v1` written
after termination
- [x] On restart, backfill recognizes migration done and exits
immediately (existing behavior — the migration check at the top of
`BackfillPathJSONAsync` was always correct; the bug was that the marker
never got written)
- [x] Test: seed DB with N observations all having `path_json = '[]'` →
backfill runs once → no UPDATEs issued, migration marker written
- [ ] Disk write rate on idle staging drops from 2–3 MB/s to <100 KB/s —
to be verified by the user post-deploy
Fixes#1119.
---------
Co-authored-by: OpenClaw Bot <bot@openclaw.local>
## Summary
Implements **M1 from #1115**: batches observation/transmission INSERTs
into a single SQLite `BEGIN/COMMIT` window instead of fsyncing per
packet. At ~250 obs/sec this drops WAL fsync rate from ~20/s to ~1/s and
eliminates the `obs-persist skipped` / `SQLITE_BUSY` log spam that the
issue documents.
This is a **partial fix** — it ships the group-commit mechanism.
Acceptance items 6–7 (measured fsync rate / measured `obs-persist
skipped` rate at staging steady-state) require post-deploy observation,
and M2 (per-`tx_hash` observation buffering) is intentionally deferred.
The issue stays open for the user to verify on staging.
> Partial fix for #1115 — does not auto-close. Refs #1115.
## Mechanism
- `Store` gains an active `*sql.Tx`, `pendingRows` counter, `gcMu`, and
the `groupCommitMs` / `groupCommitMaxRows` knobs. `SetGroupCommit(ms,
maxRows)` enables the mode; `FlushGroupTx()` commits the in-flight tx.
- `InsertTransmission` lazily opens a tx on the first call after each
flush, then issues all writes through `tx.Stmt()` bindings of the
existing prepared statements. With `MaxOpenConns(1)` the connection is
already serialized; `gcMu` serializes group-commit state without
contention.
- A goroutine in `cmd/ingestor/main.go` calls `FlushGroupTx()` every
`groupCommitMs` ms. `pendingRows >= groupCommitMaxRows` triggers an
eager flush. `Close()` flushes before the WAL checkpoint so no rows are
lost on graceful shutdown.
- `groupCommitMs == 0` short-circuits to the legacy per-call auto-commit
path (statements bound to `s.db`, no tx) — current behavior preserved
byte-for-byte for operators who opt out.
## Config
Two new optional fields (ingestor-only), both documented in
`config.example.json`:
| Field | Default | Effect |
|---|---|---|
| `groupCommitMs` | `1000` | Flush window in ms. `0` disables batching
(legacy per-packet auto-commit). |
| `groupCommitMaxRows` | `1000` | Safety cap; when exceeded the queue
flushes immediately to bound memory and the crash-loss window. |
No DB schema change. No required config change on upgrade.
## Tests (TDD red → green visible in commits)
`cmd/ingestor/group_commit_test.go` — three assertions, written first as
the red commit:
- `TestGroupCommit_BatchesInsertsIntoOneTx` — 50 `InsertTransmission`
calls inside a wide window produce **0** commits until `FlushGroupTx`,
then exactly **1**; all 50 rows visible after flush. (This is the spec's
"50 observations → 1 SQLite write transaction" assertion.)
- `TestGroupCommit_Disabled` — `groupCommitMs=0` keeps every insert
immediately visible and `GroupCommitFlushes` never advances. (Spec's
"groupCommitMs=0 reverts to per-packet behavior" assertion.)
- `TestGroupCommit_MaxRowsForcesEarlyFlush` — cap=3, 7 inserts → 2
auto-flushes from the cap + 1 final manual flush = 3 total.
Red commit: `e2b0370` (stubs `SetGroupCommit` / `FlushGroupTx` so the
tests compile and fail on **assertions**, not import errors).
Green commit: `73f3559`.
Full ingestor suite (`go test ./...` in `cmd/ingestor`) stays green, ~49
s.
## Performance
This PR is the perf change itself. Local micro-test (the new
`TestGroupCommit_BatchesInsertsIntoOneTx`) shows the structural
property: 50 inserts → 1 commit. The fsync-rate measurement called out
in the M1 acceptance criteria (`~20/s → ~1/s` at 250 obs/sec) requires
staging deployment to confirm — that's the remaining open item that
keeps #1115 open after this merges.
No hot-path regressions: when `groupCommitMs > 0` we acquire one mutex
per insert (uncontended in the steady state — the connection was already
single-threaded via `MaxOpenConns(1)`). When `groupCommitMs == 0` the
code path is identical to before plus one nil-tx check.
## What this PR does NOT do (per spec)
- Does not collapse "30 observations of one packet" into 1 row write —
that's M2.
- Does not eliminate dual-writer contention with `cmd/server`'s
`resolved_path` writes.
- Does not change observation ordering or live broadcast latency.
---------
Co-authored-by: corescope-bot <bot@corescope.local>
## Summary
Fixes the broken **Filter by node** input on the Live page.
The previous implementation used a native `<datalist>` (no consistent
styling, no real autocomplete UX), only applied on `change` (Enter), and
mutated `location.hash` on commit — which the SPA router treated as a
navigation, triggering a full re-init.
## What changed
- **Markup** (`public/live.js`): replaces the `<datalist>` with a styled
custom `#liveNodeFilterDropdown` and adds combobox/listbox ARIA wiring.
- **Styling** (`public/live.css`): new `.live-node-filter-input` rules
use `color-mix` on `var(--text)` for the background and `var(--border)`
/ `var(--text)` for border + foreground — fully theme-aware. Dropdown
uses `var(--surface-1)` + `var(--border)`.
- **Behavior**: 200 ms debounced `/api/nodes/search` call as the user
types. Suggestions render with name + 8-char pubkey prefix. Clicking a
suggestion (`mousedown` so it beats blur) sets the filter to the pubkey.
- **No reload**: `applyFilterFromInput` and the clear button now use
`history.replaceState` instead of mutating `location.hash`, so the SPA
router never re-runs and the page never reloads. Enter is
`preventDefault`-ed and either selects the highlighted suggestion or
commits the typed text.
- **Keyboard**: ArrowUp/Down navigate suggestions, Esc closes, Enter
selects.
## TDD
Per `AGENTS.md`, the failing E2E test landed first (commit `74f3e92`),
then the fix made it green (commit `a5c5c65`).
The test file `test-1110-live-filter.js` (and an integrated block in
`test-e2e-playwright.js`) asserts:
1. The input's computed `background-color` is **not** hardcoded white
when `data-theme="dark"` is set.
2. The input is not vastly larger than the surrounding toolbar row.
3. Typing `"te"` shows a visible `#liveNodeFilterDropdown` with at least
one `.live-node-filter-option`.
4. Clicking a suggestion sets `_liveGetNodeFilterKeys()` to a non-empty
list **without** reloading the page (verified via a `window.__m` marker
that survives) and **without** navigating away from `#/live`.
5. Pressing **Enter** in the filter input never reloads or navigates.
### How to run the E2E
```
go build -o /tmp/corescope-server ./cmd/server
/tmp/corescope-server -port 13581 -db test-fixtures/e2e-fixture.db -public public &
CHROMIUM_PATH=/usr/bin/chromium-browser BASE_URL=http://localhost:13581 \
node test-1110-live-filter.js
# 4/4 passed
```
## Acceptance criteria from #1110
- [x] Filter input visually matches Live page toolbar (theme-aware bg,
border, padding)
- [x] Typing 1+ characters shows dropdown of matching node names
- [x] Selecting a suggestion filters the live feed immediately
- [x] Clearing input restores unfiltered view
- [x] No page reload on any interaction with the input
- [x] E2E test asserts: type → suggestions appear → click suggestion →
feed filters → no navigation
Fixes#1110
---------
Co-authored-by: Kpa-clawbot <kpa-clawbot@users.noreply.github.com>
Fixes#1111.
## Problem
When the user has no PSK channels added, `public/channels.js` still
renders the "My Channels 🖥️ (this browser)" section header plus an
empty-state placeholder ("No channels yet — click [+ Add Channel] to
add one."). The section should not exist in the DOM at all when empty.
## Fix
Wrap the entire My Channels section render in a `mine.length > 0`
guard. When `mine.length === 0`: no section, no header, no placeholder.
## TDD
- **Red commit** (`b8bf938`): adds `test-channel-issue-1111-e2e.js`,
which fails on the current renderer because the section always
emits — the test reproduces the bug.
- **Green commit** (`776653d`): the conditional render in
`public/channels.js` makes the test pass.
## E2E
New test: `test-channel-issue-1111-e2e.js` (wired into the deploy
workflow alongside the other channel E2Es).
- Case 1: clear `localStorage` → asserts `.ch-section-mychannels`
absent and no "My Channels" text in `#chList`.
- Case 2: seed `corescope_channel_keys` with one PSK key → asserts
`.ch-section-mychannels` exists with the "My Channels" header.
## Acceptance criteria
- [x] No "My Channels" section when empty (no header, no placeholder)
- [x] Section + header + channel row render with ≥1 stored PSK key
- [x] E2E covers both states
## Performance
None — single conditional around an existing render path.
---------
Co-authored-by: Kpa-clawbot <kpa-clawbot@users.noreply.github.com>
Co-authored-by: clawbot <bot@kpabap.invalid>
Fixes#1105.
Polish follow-ups from #1104's independent review
(https://github.com/Kpa-clawbot/CoreScope/pull/1104#issuecomment-4381850096).
All 9 MINORs addressed.
## Hardening (`public/app.js`, commit fa58cb6)
1. **`GUTTER = 24` magic constant** → live
`getComputedStyle(navLeft).columnGap` read. The "matches `--space-lg`"
assertion now lives in CSS, not a stale JS literal.
2. **`fits()` conflated two distinct gaps** → reads `.nav-left`'s gap
(between brand/links/more/right cells) and `.nav-links`'s gap (between
link items) separately. Today both are `--space-lg=24px`, but a future
divergence won't silently miscompute fit.
3. **Implicit 1101px media-query flip dependency** → comment added
explaining that `.nav-stats` toggles `display:none ↔ flex` at the
boundary, and the rAF-debounced resize handler runs *after* the layout
flip so `navRightEl.scrollWidth` reflects the post-flip value.
4. **Outer null-guard widened** → now also covers `linksContainer`,
`navRightEl`, `navLeft`, `navTop`. Belt-and-braces.
5. **Cloned link listener parity** → More-menu clones now also get
`closeNav()` in addition to `closeMoreMenu()`, matching the listener
inline links get at hamburger init. Clicks from the More menu now
collapse the hamburger panel just like inline link clicks.
6. **`overflowQueue` ordering** → comment added documenting the
`data-priority="high"` signal + reverse construction; explicit
numeric-priority migration path noted.
7. **`moreW` hard-coded `70` fallback** → now caches the live measured
width the first time the More button is rendered visible;
`MORE_BTN_RESERVE_PX = 70` only used as the conservative initial guess
until that capture happens.
## Tests (`test-nav-priority-1102-e2e.js`, commit 5e9872c)
8. **Identity, not cardinality** (MINOR 7): at 1080/800px the test
asserts the visible set is EXACTLY `[#/home, #/packets, #/map, #/live,
#/nodes]`. A buggy queue that hid Home and showed Lab would still pass
`visibleCount >= 5` — that's no longer enough.
9. **Active-mirroring** (MINOR 9): new case navigates to `#/observers`
at 1080px (a route whose link overflows into the More menu) and asserts
the inline link is overflowed, the More-menu clone has `.active`, and
`#navMoreBtn` has `.active`. Exercises `rebuildMoreMenu`'s
active-mirroring path, which depends on `applyNavPriority` running on
`hashchange` after the route handler.
10. **CI hookup** (MINOR 8): `deploy.yml` now runs
`test-nav-priority-1102-e2e.js` with `CHROMIUM_REQUIRE=1`, so a Chromium
provisioning regression fails the build instead of silently SKIPing
(matching the existing `test-nav-fluid-1055-e2e.js` invocation).
## Why no red-then-green
Per AGENTS.md TDD section: hardening commit is a pure
code-quality/null-guard refactor — existing tests stay green and
unaltered (the loose `visibleCount >=` assertions still pass against the
new code). Test-improvement commit tightens assertions for behaviour
that already works (high-priority pinning, active-mirroring); there's no
production change to gate. Both branches of "exempt from red→green" are
documented in the commit messages.
## E2E / browser validation
Test runs against the Go server fixture (`-port 13581 -db
test-fixtures/e2e-fixture.db`). All 5 cases (4 viewport cases + new
active-mirror case) expected to pass; CI will run them with
`CHROMIUM_REQUIRE=1` so any Chromium provisioning regression hard-fails.
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
## Summary
Strips the Share Channel modal (shipped in #1090) down to its
essentials. Removes redundant affordances that the QR already provides.
## What changed
**Removed from the Share modal:**
- The URL text printed inside the QR box (the QR encodes the URL)
- The inline Copy Key button inside the QR box (overlapped the image)
- The `meshcore://` URL input field below the QR
- The Copy URL button next to the URL field
**Result — the modal now contains exactly:**
- Title `Share: <Channel Name>`
- QR code (just the QR `<img>`, nothing else in that box)
- Hex Key field with a single Copy button BELOW the QR
- Privacy warning
- ✕ close button (top right)
## Implementation
- `public/channels.js` — drop the `meshcore://` URL field-group from
share modal markup; `openShareModal()` no longer looks up `#chShareUrl`
or builds a URL into a field; pass `{ qrOnly: true }` when calling
`ChannelQR.generate` so the QR box renders ONLY the QR image.
- `public/channel-qr.js` — `generate(name, secret, target, opts)` now
accepts `opts.qrOnly` which short-circuits before appending the inline
URL line + Copy Key button. Default behaviour (no opts) unchanged, so
the Add-Channel "Generate & Show QR" flow is untouched.
## Tests (TDD: red → green)
- New: `test-channel-issue-1101.js` (static grep) — asserts the URL
field is gone from markup, `openShareModal` no longer references it, and
`ChannelQR.generate` honours `qrOnly`.
- Updated: `test-channel-issue-1087.js` and
`test-channel-issue-1087-e2e.js` — those previously asserted the URL
field's presence (which is exactly what #1101 removes); they now assert
ONLY the hex key field exists, AND that `#chShareQr` contains exactly
one `<img>` and no `.channel-qr-url` / `.channel-qr-copy` children.
- Wired into `.github/workflows/deploy.yml` `node-test` job.
Commit history shows red (test commit `c0c254a`) → green (fix commit
`6315a19`) per AGENTS.md TDD requirement.
E2E assertion added: test-channel-issue-1087-e2e.js:184
## Acceptance criteria
- [x] Share modal contains only: QR, "Copy Key" button, privacy warning
- [x] No "Copy URL" affordance anywhere in the modal
- [x] No duplicated hex key field below
- [x] E2E test asserts the absence of the removed elements
Fixes#1101
---------
Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
## Summary
Fixes#1102 — regression from PR #1097 polish where Priority+ collapsed
too aggressively at wide widths and the "More" menu didn't reflect what
was actually hidden.
## Root cause
Two bugs, one root: the post-#1097 CSS rule
```css
.nav-links a:not([data-priority="high"]) { display: none; }
```
unconditionally hid 6 of 11 links at every width ≥768px — including
2560px where everything fits comfortably. The "More" menu populator
(`querySelectorAll('.nav-links a:not([data-priority="high"])')`) ran
exactly once on load against that same selector, so it always held the
same 6 links and never reflected the actual viewport fit.
## Fix
Replace the static CSS hide with a JS measurement pass
(`applyNavPriority` in `public/app.js`):
1. Start with **all** links visible inline.
2. Compute `needed = brand + gutters + visible-links + More + nav-right
+ safety` and compare to `window.innerWidth`.
3. If it doesn't fit, mark the rightmost (lowest-priority) link
`.is-overflow` and re-measure. Repeat. High-priority links are queued
last so they're kept visible as long as possible.
4. Rebuild the "More ▾" menu from whatever currently has `.is-overflow`.
When nothing overflows, hide the More wrap entirely (`.is-hidden`).
5. Re-run on resize (rAF-debounced), on `hashchange` (active-link
padding shifts), and after fonts load.
Why JS, not CSS: the breakpoint where each link drops depends on label
width, gutters, active-link padding, and the nav-stats badge — none of
which are addressable from a media query.
## TDD trail
- Red commit `8507756`: `test-nav-priority-1102-e2e.js` — fails 2/4
(2560 and 1920 only show 5/11).
- Green commit `3e84736`: implementation — passes 4/4.
## E2E
`test-nav-priority-1102-e2e.js` asserts:
- 2560px → all 11 visible, More hidden
- 1920px → ≥9 visible
- 1080px → 5+ visible AND More menu contains every hidden link
- 800px → 5+ visible AND More menu non-empty
Local run on the e2e fixture: **4/4 pass**. Existing
`test-nav-fluid-1055-e2e.js` also stays green: **20/20 pass** (no
overlap, no overflow at 768/1024/1280/1440/1920 across 4 routes).
---------
Co-authored-by: meshcore-bot <bot@meshcore.local>
## Summary
Makes the channels page sidebar + message area fluid as part of the
parent #1050 fluid-layout effort. Replaces the hardcoded
`.ch-sidebar { width: 280px; min-width: 280px }` with
`width: clamp(220px, 22vw, 320px); min-width: 220px`. Adds an
`@container` query (via `container-type: inline-size` on `.ch-layout`)
that stacks the sidebar above the message area when the channels
page itself is narrow (≤700px container width) — independent of
the global viewport, so it adapts even when an outer panel is
consuming width. Removes the legacy `@media (max-width: 900px)`
fixed 220px override; the clamp + container query handle that range.
`.ch-main` already used `flex: 1`, so it absorbs all remaining width
including ultrawides. The existing mobile (≤640px) overlay rules and
the JS resize handle in `channels.js` are untouched and still work
(user drag still wins via inline width).
Fixes#1057.
## Scope
- `public/style.css` — channels section only
- (no `public/channels.js` changes needed)
## Tests
TDD: red commit (failing tests) → green commit (implementation).
- `test-channel-fluid-layout.js` (new): static CSS assertions
- `.ch-sidebar` uses `clamp()` for width (not fixed px)
- `.ch-sidebar` keeps a sane `min-width` (200–280px)
- `.ch-main` keeps `flex: 1`
- `.ch-layout` declares `container-type` (container query root)
- `@container` rule scopes channels stacking
- legacy `@media (max-width: 900px) .ch-sidebar { width: 220px }` is
gone
- `test-channel-fluid-e2e.js` (new): Playwright E2E at
768 / 1080 / 1440 / 1920 (wide) and 480 (narrow). Asserts:
- no horizontal scroll on the body
- sidebar AND message area both visible side-by-side at ≥768px
- sidebar consumes ≤45% of viewport, main ≥40%
- at 480px the layout stacks (or overlays) — no overflow
Wired into `test-all.sh` and the unit + e2e steps of
`.github/workflows/deploy.yml`.
## Verification
- Static unit test: 6/6 pass on the green commit, 4/6 fail on the
red commit (only the two trivially-true assertions pass).
- Local Go server boot: `corescope-server` serves the updated
`style.css` containing `container-type: inline-size`,
`clamp(220px, 22vw, 320px)`, and `@container chlayout (max-width:
700px)`.
- Local Chromium on the dev sandbox is musl-incompatible
(Playwright fallback build crashes with `Error relocating ...:
posix_fallocate64: symbol not found`), so the E2E was not run
locally. CI will run it on Ubuntu runners.
---------
Co-authored-by: clawbot <clawbot@example.com>
Co-authored-by: meshcore-bot <bot@meshcore.local>
## Summary
Make the top-nav use the **Priority+ pattern at all widths** (not just
768–1279px), so the nav-right cluster never gets pushed off-screen or
visually overlapped by the link strip.
Fixes#1055.
## What changed
**`public/style.css`** — nav section only (clearly fenced):
- Removed the upper bound on the Priority+ media query (`max-width:
1279px`). The rule now applies at any viewport `>= 768px`. Above that
breakpoint, only `data-priority="high"` links render inline; the rest
collapse into the existing `More ▾` overflow menu.
- Swapped nav-only hardcoded spacing/type to the fluid `clamp()` tokens
shipped in #1054:
- `.top-nav` padding → `var(--gutter)`
- `.nav-left` gap → `var(--space-lg)`
- `.nav-brand` gap → `var(--space-sm)`, font-size → `var(--fs-md)`
- `.nav-links` gap → `var(--space-xs)`
- `.nav-link` padding → `clamp(8px, 0.6vw + 4px, 14px)`, font-size →
`var(--fs-sm)`
- `.nav-right` gap → `var(--space-sm)`
- Mobile (<768px) hamburger layout, the More-menu markup, and the JS
that builds the menu in `public/app.js` are unchanged — they already
supported this pattern.
`public/index.html` did not need changes — the `data-priority="high"`
markup, `nav-more-wrap`, `navMoreBtn`, and `navMoreMenu` are already in
place from earlier work.
## Why the bug existed
The previous Priority+ rule was scoped `@media (min-width: 768px) and
(max-width: 1279px)`. From 1280px–~1599px the full 11-link strip
rendered but didn't fit alongside `.nav-stats` + `.nav-right`. The
parent `overflow: hidden` masked the symptom, but the rightmost links
physically rendered underneath `.nav-right` and were unreachable.
## E2E assertion added
New `test-nav-fluid-1055-e2e.js` — Playwright multi-viewport test
(768/1024/1280/1440/1920) that asserts:
1. `.nav-right.right` ≤ `document.documentElement.clientWidth` (no
horizontal overflow)
2. Last visible `.nav-link.right` ≤ `.nav-right.left` (no overlap
underneath the right cluster)
3. `.top-nav.scrollWidth` ≤ `.top-nav.clientWidth` (no scrolled-off
content)
Wired into the `e2e-test` job in `.github/workflows/deploy.yml`.
**TDD evidence:**
- Red commit `466221a`: test passes 3/5 (1024/768/1920) — fails at 1280
(253px overlap) and 1440 (93px overlap).
- Green commit `1aa939a`: test passes 5/5.
## Acceptance criteria (from #1055)
- [x] Priority+ at ALL widths (not just mobile).
- [x] No nav link overflow at 1080px (or any tested width).
- [x] Overflow menu accessible via keyboard + touch (existing
`navMoreBtn` aria-haspopup wiring; verified by existing app.js
handlers).
- [x] Active route still highlighted when in overflow (existing logic in
`app.js` adds `.active` to the cloned link in `navMoreMenu`).
- [x] Tested at 768/1024/1280/1440/1920 — visible link count adapts (5
priority links + More menu at all desktop widths; full 11 inline only on
hamburger mobile when expanded).
---------
Co-authored-by: bot <bot@corescope>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
Co-authored-by: meshcore-bot <bot@meshcore.local>
## Summary
Fixes#1059 — Task 6 of #1050. Makes map controls + modals fluid and
safely capped so they work across 768px–2560px viewports.
## Changes
`public/style.css` only — modal section + map-controls section (per task
scope).
### Map controls (`.map-controls`)
- `width: clamp(160px, 18vw, 240px)` — fluid, scales with viewport.
- `max-width: calc(100vw - 24px)` — never overflows narrow viewports.
- Eliminates horizontal scroll on the map page at
768/1024/1440/1920/2560.
### Modal box (`.modal`)
- `max-height: 80vh → 90vh` (spec §3).
- `width: min(90vw, 500px)` — fluid, drops to 90vw below 555px.
- `position: relative` so sticky descendants anchor to the modal box.
- `.modal-overlay` gets `padding: clamp(8px, 2vw, 24px)` for edge
breathing room.
### BYOP modal sticky close
- `.byop-header { position: sticky; top: 0 }` with `var(--card-bg)`
backdrop and bottom border — the title bar + ✕ stay reachable while the
body scrolls.
- `.byop-x` restyled with border, hit area, hover state.
### Untouched (intentional)
- `public/map.js` did not need changes — the `.map-controls` element is
the only narrow-viewport offender; the markup stays identical.
- Channel modals (`.ch-modal*`, `.ch-share-modal*`) already have their
own width/max-width tokens from #1034/#1087 and are out of scope for
this task.
## TDD
- **Red commit** `b69e992`: `test-map-modal-fluid-e2e.js` asserts (a) no
horizontal scroll on `/#/map` at 1024/1440/1920/2560, (b)
`.map-controls` right edge inside viewport at 768px wide, (c) BYOP modal
at 1024×768 has `height ≤ 90vh`, `overflow-y: auto|scroll`, and close
button is `position: sticky` and reachable. All assertions fail against
the previous CSS (fixed-width 220px controls overflow at narrow widths;
modal max-height was 80vh, not 90vh; close button was `position:
static`).
- **Green commit** `3e6df9d`: CSS changes above; all assertions pass.
## E2E
- Wired into `.github/workflows/deploy.yml` after the channel-1087 E2E:
```
BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js
```
## Acceptance criteria
- [x] Map controls do not overlap markers at narrow viewports (fluid
clamp width + max-width).
- [x] Map fills extra space on ultrawide (panel caps at 240px, leaflet
flex:1 takes the rest — already true; controls no longer steal grow
room).
- [x] Modals: `max-height: 90vh`, internal scroll, sticky close button,
max-width via `min()`.
- [x] No modal can exceed viewport height at any tested width.
- [x] Verified via E2E at 768/1024/1440/1920/2560.
## Out of scope (left for sibling tasks under #1050)
- Tab bars / nav (Task 1050-1, blocker).
- Filter bars and table chrome (other 1050-N tasks).
---------
Co-authored-by: corescope-bot <bot@corescope.local>