Files
meshcore-analyzer/test-issue-1418-deeplink-hops-channels.js
T
Kpa-clawbot 77d1925f30 Route view v2 — Tufte redesign (packet context, multi-path picker, mobile bottom-sheet, CB-preset live colors) (#1423)
# Route view v2 redesign

Fixes #1418, Fixes #1419, Fixes #1422

This is the route-view redesign that came out of a long iterative QA
cycle. The first commit (`a3c39636`) landed the v1 sidebar timeline +
multi-path baseline; this PR's second commit (`0e2e913f`) is the v2
polish covering packet context, multi-path picker, mobile bottom-sheet,
CB-preset live colors, and dozens of operator-driven UX fixes.

## The journey, in one line

> "The data is a sequence. Geography is annotation. The packet is the
cargo, the route is the road — show both."

## New surfaces

### 1. Packet context block (sidebar header)
Above the multi-path chip, a per-type fact list explaining **what** is
traveling. Operator was tired of "the route view shows the road but not
the cargo."

| Type | Chip | Facts |

|-------------|-----------------|---------------------------------------------------------|
| ADVERT | 📡 ADVERT | name · role · sig ✓ · self-reported GPS · pubkey
prefix |
| TXT_MSG | ✉ DM | src → dst · 🔒 encrypted |
| REQ/RESPONSE| 🔒/🔓 REQUEST/…| src → dst · 🔒 encrypted |
| GRP_TXT | # CHANNEL MSG | #channel · 🔓 decrypted · "…content preview…"
· sender |
| TRACE | ⌖ TRACE | Official: N hops · Observed: M |
| PATH | 🔀 PATH | src → dst (with "from payload" chip on SRC/DST rows) |

Sources merge `pkt.decoded_json` + `obs.decoded_json` (channel data
often lives at packet level) and fall back to byte-level `raw_hex`
parsing for encrypted DMs and unkeyed channel msgs.

### 2. Multi-path picker
The header lists every unique observer-path with `<count>/<total>` chip
+ hex hop string. Click a path → full-clear and redraw that path only
(Tufte v6's "replace + retain subpath weights"). "All" →
edge-deduplicated UNION view (each unique edge drawn once, stroke =
observer count, single accent color, no seq numbers because there's no
single ordering).

### 3. Deep-link URLs
`#/map?packet=<hash>&obs=<id>` — bookmarkable, shareable, the single
source of truth. sessionStorage flow removed. "Back to packet" preserves
the obs id.

### 4. Hop resolution
Priority: server `resolved_path` → shared `window.HopResolver` (same
resolver as packets page, observer-IATA-aware) → raw prefix. Eliminates
a whole class of "route view named hops differently than packet detail"
bugs.

### 5. Markers (v5/v6/v7)
- All markers same 22 px filled circle, seq number rendered **inside**
- SRC + DST get a 2 px hollow endpoint ring
- SRC = DST loop → **double concentric ring** (ring grammar extended, no
new glyph)
- Spider-fan within 14 px collisions (16 px arc, dashed hairline),
re-runs on `zoomend` only, debounced

### 6. CB preset live colors
- Each preset gets a `routeRamp` (5 stops): default/trit = viridis,
deut/prot = plasma, achromat = pure luminance
- `cb-presets.js` writes `--mc-rt-ramp-0..4` CSS vars; route reads them
via `getComputedStyle`
- `cb-preset-changed` + `theme-changed` listeners hot-recolor without
re-render

### 7. Desktop chrome
- **Resize handle** on right edge of sidebar (drag, persisted to
`localStorage["mc-rt-sidebar-width"]`)
- **Collapse button** = round chevron **centered on the right edge**
(Material/Drive style — not in the top-right corner, doesn't collide
with the close X)
- Collapsed = 36 px strip with rotated "ROUTE" label, expand on click

### 8. Mobile (bottom sheet)
- Anchored above bottom-nav (`bottom: 56px + safe-area-inset`)
- Collapsed = thin summary line `TYPE · N hops · X km · M obs` + hex
preview, tap chevron to expand to ~75 vh
- Drag-grip removed (conflicted with browser pull-to-refresh +
CoreScope's own pull-to-reconnect)
- Desktop collapse / resize affordances hidden on mobile (sheet is the
mobile collapse affordance)
- Map controls toggle floats top-right, panel collapses on route entry,
reachable via toggle click
- All three mobile detail panels (`pktRight`, `.slide-over-panel`,
`#mobileDetailSheet`) explicitly closed when entering route view

### 9. Map fit / centering
- Manual layer-children walk because `L.LayerGroup.getBounds()` doesn't
aggregate (only `FeatureGroup` does)
- Mobile padding: `paddingTopLeft: [30, 70]`, `paddingBottomRight: [30,
190]` to clear top-nav + sheet+nav stack
- Re-fits on: initial render, isolate, All, `window.resize` (iOS URL-bar
collapse)
- Staggered timers 0/200/600/1400 ms (and 2800 ms on initial render) to
survive layout settles

### 10. Hop drill-in refinements
- SNR sparkline suppresses connecting polyline when n < 3 (two points
implies a trend across time it can't represent — dots only)
- "Node details" link properly chip-styled with aria-label including
node name + route count

## Edge weight scales

| View                            | Range          |
|---------------------------------|----------------|
| Single-path                     | 5 px flat      |
| Multi-path interior             | 3..9           |
| Origin→hop1 / last-hop→dest     | proxy via max adjacent edge count |
| Union overlay                   | 2..8           |

Boundary edges (SRC→first hop, last hop→DST) used to render thin because
`edgeCounts` only tracks `path_json` transitions. Now they take the
strongest adjacent edge count as proxy (every observer who saw the
packet implicitly transited that boundary edge).

## Files

- **NEW** `public/route-tufte.js` (~1700 lines) — the route renderer +
sidebar
- **NEW** `public/route-tufte.css` (~750 lines) — all styling
- **MOD** `public/map.js` — async draw functions, deep-link loader,
`__mc_nodes` exposure, raw_hex extraction
- **MOD** `public/packets.js` — View Route → deep-link URL only, closes
all mobile panels
- **MOD** `public/cb-presets.js` — `routeRamp` per preset + CSS var
write
- **MOD** `public/index.html` — script + stylesheet tags

## Testing

Manually CDP-validated across desktop and mobile-emulator viewports for
every major change. Fixtures cover:
- ADVERT (4 hops, single-obs)
- DM (TXT_MSG, raw_hex parse)
- GRP_TXT (#test channel, decrypted text)
- PATH (operator's bug case)
- TRACE (3-hop)
- 1-hop edge case
- Multi-path (75-observer 4-hop with 47 unique paths)
- 32-hop stress
- Loop (SRC = DST)
- Bay Area dense cluster (spider-fan)

Per AGENTS.md net-new-UI exemption, no failing-test-first; existing
tests stay green. **TODO**: Playwright E2E follow-up PR.

## What's deferred to v2.1 / follow-ups

- **Glyph overlay on SRC marker** for packet type (e.g. 📡 corner glyph
on ADVERT marker, ⌖ on TRACE)
- **Per-hop SNR sparkline for TRACE packets** (their payload contains
real per-hop SNR contributions, distinct from observer-derived SNR)
- **GRP_TXT full content preview** (currently truncated at 80 chars;
could expand inline)
- **Playwright E2E test** covering the deep-link → isolate → All flow

## Screenshots

(would be useful here — CDP screenshots captured during dev show:
desktop with sidebar + multi-path picker, mobile with bottom sheet +
overlay toggle, isolated-path view, union view, spider-fan on Bay Area
cluster, packet context for each of the 5 main types)

## Operator's frustration patterns (lessons for next time)

1. **Browser-validate every UI change, not just compute state** —
CDP-screenshot before claiming a UI fix is done. Verifying
`display:none` resolves correctly is necessary but not sufficient; the
visual layout matters.
2. **Edge-deduplicated drawing beats per-path overlays** for union views
(Tufte v6) — operator's instinct was correct from the start.
3. **Material/Drive UI conventions exist** because they work — center
collapse handles on borders, don't pile them in corners.
4. **Mobile = different problem than desktop** — bottom-sheet, no
drag-grip near pull-to-refresh zone, asymmetric fitBounds padding,
redundant refits to survive iOS URL-bar collapse.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-27 08:01:15 +00:00

220 lines
9.5 KiB
JavaScript

/**
* #1418 — map.js loadRouteFromDeepLink:
* - Hop resolution priority (server resolved_path > HopResolver > raw).
* - GRP_TXT channel hash → name resolution (enc_ placeholder, SHA-256 byte
* match for keyed channels, fallback to "channel 0x<HEX>").
*
* The deep-link loader is a giant async function; we don't run it end-to-end.
* Instead we verify:
* 1. Source invariants: priority order is unambiguous in code.
* 2. Replica of the chosen-path resolution logic, exercised on fixtures.
* 3. Replica of the channel-match predicate (the same `find` callback).
* 4. Live SubtleCrypto comparison: SHA-256(name)[0] === target byte
* reproduced via node's built-in crypto.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
console.log('\n=== #1418 hop-priority A: source invariants (3-tier priority) ===');
// Priority comment is documented; assert the structural keywords are in order.
const priorityBlock = mapSrc.match(/Priority:[\s\S]{0,800}rawHops/);
assert(!!priorityBlock,
'priority block documented in map.js');
if (priorityBlock) {
const blk = priorityBlock[0];
const iResolved = blk.indexOf('resolved_path');
const iHopRes = blk.indexOf('HopResolver');
const iRaw = blk.indexOf('raw');
assert(iResolved >= 0 && iHopRes >= 0 && iRaw >= 0,
'priority block mentions all three: resolved_path, HopResolver, raw');
assert(iResolved < iHopRes && iHopRes < iRaw,
'priority order in comment: resolved_path → HopResolver → raw');
}
// Structural code path: resolved_path branch checked first, then HopResolver,
// then naked rawHops fallback.
assert(/if\s*\(\s*Array\.isArray\(resolvedHops\)[^\)]*\)\s*\{[\s\S]{0,200}\}\s*else if\s*\(\s*window\.HopResolver/.test(mapSrc),
'code structure: if (resolvedHops valid) else if (window.HopResolver) else (rawHops)');
console.log('\n=== #1418 hop-priority B: replica of chosen-path selection ===');
// Replicate the chooseChosenPath logic exactly. window.HopResolver shim
// returns a per-pubkey dict; resolveResult[h] is consulted per raw hop.
function chooseChosenPath(rawHops, resolvedHopsRaw, hopResolver) {
let resolvedHops = null;
try {
if (resolvedHopsRaw) {
resolvedHops = typeof resolvedHopsRaw === 'string' ? JSON.parse(resolvedHopsRaw) : resolvedHopsRaw;
}
} catch (_) {}
if (Array.isArray(resolvedHops) && resolvedHops.length === rawHops.length) {
return rawHops.map((h, i) => resolvedHops[i] || h);
}
if (hopResolver && typeof hopResolver.resolve === 'function' && rawHops.length) {
try {
const result = hopResolver.resolve(rawHops);
return rawHops.map(h => {
const r = result ? result[h] : null;
return r && r.pubkey ? r.pubkey : h;
});
} catch (_) { return rawHops; }
}
return rawHops;
}
const rawHops = ['AA', 'BB', 'CC'];
// Tier 1: server resolved_path takes priority over HopResolver
const serverResolved = ['AAFULL1', 'BBFULL2', 'CCFULL3'];
const naiveResolver = { resolve: () => ({ AA: { pubkey: 'WRONG_A' }, BB: { pubkey: 'WRONG_B' }, CC: { pubkey: 'WRONG_C' }}) };
let chosen = chooseChosenPath(rawHops, serverResolved, naiveResolver);
assert(JSON.stringify(chosen) === JSON.stringify(serverResolved),
'server resolved_path wins over HopResolver (returns ' + JSON.stringify(chosen) + ')');
// Tier 1 with JSON string input (server returns it stringified sometimes)
chosen = chooseChosenPath(rawHops, JSON.stringify(serverResolved), naiveResolver);
assert(JSON.stringify(chosen) === JSON.stringify(serverResolved),
'server resolved_path accepts JSON-string input (parses it)');
// Tier 2: no resolved_path → use HopResolver
const smartResolver = { resolve: () => ({ AA: { pubkey: 'AAFULL_DIFF' }, BB: { pubkey: 'BBFULL_DIFF' }, CC: { pubkey: 'CCFULL_DIFF' }}) };
chosen = chooseChosenPath(rawHops, null, smartResolver);
assert(JSON.stringify(chosen) === JSON.stringify(['AAFULL_DIFF', 'BBFULL_DIFF', 'CCFULL_DIFF']),
'no resolved_path → HopResolver result used (returns ' + JSON.stringify(chosen) + ')');
// HopResolver returns different from naive prefix → values change
chosen = chooseChosenPath(['AB'], null, { resolve: () => ({ AB: { pubkey: 'ABcorrect123' } }) });
assert(chosen[0] === 'ABcorrect123',
'HopResolver overrides naive prefix when it returns a longer pubkey');
// HopResolver throws → fallback to raw
chosen = chooseChosenPath(rawHops, null, { resolve: () => { throw new Error('boom'); } });
assert(JSON.stringify(chosen) === JSON.stringify(rawHops),
'HopResolver throw → fallback to rawHops');
// Tier 3: no resolved_path, no HopResolver → raw prefixes
chosen = chooseChosenPath(rawHops, null, null);
assert(JSON.stringify(chosen) === JSON.stringify(rawHops),
'no resolved_path AND no HopResolver → raw prefixes returned as-is');
// Length mismatch: resolved_path is wrong length → falls through to HopResolver
chosen = chooseChosenPath(rawHops, ['only_one'], smartResolver);
assert(JSON.stringify(chosen) === JSON.stringify(['AAFULL_DIFF', 'BBFULL_DIFF', 'CCFULL_DIFF']),
'resolved_path with mismatched length → falls through to HopResolver');
// Per-element falsy in resolved_path → falls back to raw for THAT index
chosen = chooseChosenPath(rawHops, ['AAFULL1', null, 'CCFULL3'], null);
assert(JSON.stringify(chosen) === JSON.stringify(['AAFULL1', 'BB', 'CCFULL3']),
'per-index null in resolved_path → falls back to raw for that index only');
console.log('\n=== #1418 channel A: GRP_TXT match predicate (sync part) ===');
// Replica of the channel-find predicate from loadRouteFromDeepLink.
function findChannelSync(chList, wantHex) {
const wantUp = String(wantHex).toUpperCase();
return chList.find(c => {
const ch = String(c.hash || '').toUpperCase();
const nm = String(c.name || '').toUpperCase();
return ch.startsWith(wantUp) ||
ch === 'ENC_' + wantUp ||
nm.includes('0X' + wantUp);
}) || null;
}
const channels = [
{ hash: 'public_full_hash_AB...', name: 'Public' },
{ hash: 'enc_77', name: 'Encrypted (0x77)', encrypted: true },
{ hash: 'unknown', name: 'channel 0xCD' }
];
// hash starts with target hex
let m = findChannelSync([{ hash: 'AB1234', name: 'Test' }], 'AB');
assert(m && m.name === 'Test', 'finds channel where hash starts with target hex');
// enc_<HEX> placeholder
m = findChannelSync(channels, '77');
assert(m && m.name === 'Encrypted (0x77)',
'matches enc_<HEX> placeholder ("enc_77") for encrypted channel');
// name contains "0x<HEX>"
m = findChannelSync(channels, 'CD');
assert(m && m.name === 'channel 0xCD',
'matches name containing "0x<HEX>" placeholder');
// Case-insensitive
m = findChannelSync([{ hash: 'enc_ff', name: 'lower' }], 'FF');
assert(m && m.name === 'lower', 'case-insensitive match on enc_<HEX>');
// No match → null (caller falls back to "channel 0x<HEX>")
m = findChannelSync(channels, 'XX');
assert(m === null, 'no match → null (so caller renders "channel 0x<HEX>" fallback)');
console.log('\n=== #1418 channel B: SHA-256(name)[0] keyed-channel match ===');
// The async fallback (SubtleCrypto) computes SHA-256(name)[0] and checks
// it against the target byte. Reproduce in node and verify the formula
// matches the firmware/decoder convention (first byte of SHA-256).
function sha256Byte0(name) {
const buf = crypto.createHash('sha256').update(name, 'utf8').digest();
return buf[0].toString(16).padStart(2, '0').toUpperCase();
}
// Known channel name → its derived byte
const wellKnown = ['Public', 'Test Channel', 'mesh-control', 'general'];
wellKnown.forEach(name => {
const byte = sha256Byte0(name);
assert(/^[0-9A-F]{2}$/.test(byte),
'SHA-256("' + name + '")[0] = 0x' + byte + ' (valid 2-hex)');
});
// Construct a fixture where we deliberately want to match channel "Public"
const target = sha256Byte0('Public');
// Simulate the async match loop: walk the channel list, hash each name,
// return the one whose first byte === target.
function findChannelAsync(chList, wantHex) {
const wantUp = String(wantHex).toUpperCase();
for (const c of chList) {
if (c.encrypted) continue;
if (!c.name) continue;
if (sha256Byte0(c.name) === wantUp) return c;
}
return null;
}
const result = findChannelAsync([
{ name: 'Public' },
{ name: 'Other' },
{ name: 'Public', encrypted: true } // would match but encrypted → skipped
], target);
assert(result && result.name === 'Public' && !result.encrypted,
'SHA-256 match: returns first non-encrypted channel whose name SHA-256[0] === target byte');
// Source invariants: the async block exists in map.js
assert(/window\.crypto\.subtle/.test(mapSrc), 'map.js uses window.crypto.subtle for SHA-256 fallback');
assert(/'SHA-256'/.test(mapSrc), 'map.js requests SHA-256 specifically');
assert(/if\s*\(c\.encrypted\)\s*continue/.test(mapSrc),
'async loop skips already-known encrypted/placeholder channels');
assert(/byteHex\s*===\s*wantUp/.test(mapSrc),
'async loop compares first-byte hex to target (byteHex === wantUp)');
console.log('\n=== #1418 channel C: fallback label format ===');
// When no match found, caller renders "Encrypted (0x<HEX>)" for encrypted,
// "channel 0x<HEX>" otherwise. Just guard the literal templates exist.
assert(/Encrypted \(0x/.test(mapSrc),
'encrypted-channel fallback label "Encrypted (0x..." present in map.js');
console.log('\n=== Summary ===');
console.log(' passed: ' + passed);
console.log(' failed: ' + failed);
if (failed > 0) process.exit(1);