@dtudury/streamo 2.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1078 @@
1
+ // streamo explorer — read-only registry / address browser.
2
+ //
3
+ // Two view kinds, navigated by URL hash:
4
+ // #/ — registry list
5
+ // #/repo/<keyHex> — at HEAD, the most-recent sig
6
+ // (symbolic, like git's HEAD ref).
7
+ // Shorthand for /at/HEAD.
8
+ // #/repo/<keyHex>/at/HEAD — same thing, explicit form.
9
+ // #/repo/<keyHex>/at/<address> — pinned to a specific byte address.
10
+ //
11
+ // When the resolved chunk is a SIGNATURE, the page is the polished
12
+ // signed-commit view (selector dropdown at top, polished detail below,
13
+ // storage chunks tucked into a <details>). Otherwise it's storage
14
+ // drilling — value/storage tabs for that chunk, no selector.
15
+ //
16
+ // State lives in plain JS variables; reactivity is bridged from each Repo's
17
+ // internal Recaller into the app-level Recaller via the `signal` pattern
18
+ // (see chat/main.js for the same approach).
19
+
20
+ import { h } from '../../streamo/h.js'
21
+ import { mount } from '../../streamo/mount.js'
22
+ import { Recaller } from '../../streamo/utils/Recaller.js'
23
+ import { RepoRegistry } from '../../streamo/RepoRegistry.js'
24
+ import { registrySync } from '../../streamo/registrySync.js'
25
+ import { changedPaths } from '../../streamo/Streamo.js'
26
+ import { hexToBytes } from '../../streamo/utils.js'
27
+
28
+ // ── Connect ───────────────────────────────────────────────────────────────
29
+
30
+ const registry = new RepoRegistry()
31
+ const port = +location.port || 80
32
+ const connEl = document.getElementById('conn')
33
+
34
+ try {
35
+ await registrySync(registry, location.hostname, port)
36
+ connEl.textContent = `connected · ${location.hostname}:${port}`
37
+ connEl.classList.add('ok')
38
+ } catch (e) {
39
+ connEl.textContent = `connection failed: ${e.message}`
40
+ connEl.classList.add('err')
41
+ throw e
42
+ }
43
+
44
+ // ── App-level reactivity ──────────────────────────────────────────────────
45
+
46
+ const recaller = new Recaller('explorer')
47
+ const signal = {}
48
+ const dep = () => recaller.reportKeyAccess(signal, 'data')
49
+
50
+ const schedule = typeof requestAnimationFrame !== 'undefined'
51
+ ? fn => requestAnimationFrame(fn)
52
+ : fn => queueMicrotask(fn)
53
+ let scheduled = false
54
+ function fire () {
55
+ if (scheduled) return
56
+ scheduled = true
57
+ schedule(() => {
58
+ scheduled = false
59
+ recaller.reportKeyMutation(signal, 'data')
60
+ // After mount has updated the DOM, sync byte-strip viewport indicators
61
+ // and (if appropriate) keep them pinned to HEAD on live updates.
62
+ syncByteStrips()
63
+ })
64
+ }
65
+
66
+ const watched = new Set()
67
+ function watchRepo (key, repo) {
68
+ if (watched.has(key)) return
69
+ watched.add(key)
70
+ repo.watch(`explorer:${key}`, () => {
71
+ repo.byteLength
72
+ fire()
73
+ })
74
+ }
75
+ for (const [k, r] of registry) watchRepo(k, r)
76
+ registry.onOpen((k, r) => { watchRepo(k, r); fire() })
77
+
78
+ // ── Hash routing ──────────────────────────────────────────────────────────
79
+
80
+ function viewFromHash () {
81
+ const m = (location.hash || '#/').match(/^#\/repo\/([0-9a-f]+)(?:\/at\/(HEAD|\d+))?\/?$/i)
82
+ if (!m) return { kind: 'registry' }
83
+ // Bare `/repo/<hex>` is shorthand for `/at/HEAD` — the symbolic pointer
84
+ // to the most recent signed commit (like git's HEAD).
85
+ const raw = m[2]
86
+ const address = raw == null || raw.toUpperCase() === 'HEAD' ? 'HEAD' : +raw
87
+ return { kind: 'at', keyHex: m[1], address }
88
+ }
89
+
90
+ function hashFromView (v) {
91
+ if (v.kind !== 'at') return '#/'
92
+ // Canonical form for HEAD is the bare URL — concise and analogous to
93
+ // tools that imply HEAD when no ref is given.
94
+ if (v.address === 'HEAD') return `#/repo/${v.keyHex}`
95
+ return `#/repo/${v.keyHex}/at/${v.address}`
96
+ }
97
+
98
+ let view = viewFromHash()
99
+ function go (next) {
100
+ view = next
101
+ const target = hashFromView(next)
102
+ if (location.hash !== target) location.hash = target
103
+ fire()
104
+ }
105
+ window.addEventListener('hashchange', () => {
106
+ const next = viewFromHash()
107
+ if (next.kind === view.kind && next.keyHex === view.keyHex && next.address === view.address) return
108
+ view = next
109
+ fire()
110
+ })
111
+
112
+ // At-view tab state — persists across at-view navigations so a user who
113
+ // wants to keep a "storage" lens on doesn't have to re-click after every
114
+ // drill-down. Reset to default on registry/repo views (set in go()).
115
+ let atTab = 'value'
116
+
117
+ // ── Helpers ───────────────────────────────────────────────────────────────
118
+
119
+ const truncKey = k => k.slice(0, 12) + '…'
120
+ const truncHex = (b, n = 16) => Array.from(b.subarray(0, n)).map(x => x.toString(16).padStart(2, '0')).join('') + (b.length > n ? '…' : '')
121
+ const fmtDate = d => d ? d.toLocaleString() : ''
122
+
123
+ function isCommitShape (v) {
124
+ return v && typeof v === 'object' && !Array.isArray(v) &&
125
+ typeof v.message === 'string' && v.date instanceof Date &&
126
+ typeof v.dataAddress === 'number'
127
+ }
128
+
129
+ function safeJSON (value) {
130
+ return JSON.stringify(value, (_, v) => {
131
+ if (v instanceof Uint8Array) return `Uint8Array(${v.length})`
132
+ if (v instanceof Date) return v.toISOString()
133
+ return v
134
+ }, 2)
135
+ }
136
+
137
+ // Walk every chunk newest-to-oldest, bundling each signature with the
138
+ // commit(s) it covers into one "signed commit" entry. A signed commit is
139
+ // the natural unit of authored history — the sig is what makes the bytes
140
+ // provably yours, and the commit gives those bytes meaning, so they read
141
+ // as one thing. Concurrent commits batched into one sign produce a single
142
+ // entry whose .commits is multi-element.
143
+ //
144
+ // Commits more recent than the latest sig (sign in flight, or none yet)
145
+ // surface as 'unsignedCommit'. Everything else — Duples, OBJECTs that
146
+ // aren't commits, ARRAYs, STRINGs, etc. — is 'other' and lives in the
147
+ // storage section.
148
+ function * signedCommits (repo) {
149
+ const len = repo.byteLength
150
+ if (len <= 0) return
151
+ let addr = len - 1
152
+ let pendingSig = null // most-recent sig still gathering its commits
153
+ let pendingCommits = [] // commits since the last yielded sig (newest first)
154
+
155
+ const flush = () => {
156
+ if (pendingSig) return [{ kind: 'signedCommit', ...pendingSig, commits: pendingCommits }]
157
+ return pendingCommits.map(c => ({ kind: 'unsignedCommit', ...c }))
158
+ }
159
+
160
+ while (addr >= 0) {
161
+ const code = repo.resolve(addr)
162
+ if (!code || !code.length) break
163
+ const type = repo.footerToCodec[code.at(-1)]?.type
164
+
165
+ if (type === 'SIGNATURE') {
166
+ for (const e of flush()) yield e
167
+ let sig
168
+ try { sig = repo.decode(addr) } catch { sig = null }
169
+ pendingSig = sig
170
+ ? {
171
+ sigAddress: addr,
172
+ signedFrom: sig.address,
173
+ signedTo: addr - code.length,
174
+ sigHex: truncHex(sig.compactRawBytes, 12)
175
+ }
176
+ : null
177
+ pendingCommits = []
178
+ } else if (type === 'OBJECT') {
179
+ let value
180
+ try { value = repo.decode(addr) } catch { value = null }
181
+ if (isCommitShape(value)) {
182
+ pendingCommits.push({
183
+ address: addr,
184
+ message: value.message,
185
+ date: value.date,
186
+ dataAddress: value.dataAddress,
187
+ parent: value.parent
188
+ })
189
+ } else {
190
+ yield { kind: 'other', address: addr, codecType: type }
191
+ }
192
+ } else {
193
+ yield { kind: 'other', address: addr, codecType: type }
194
+ }
195
+ addr -= code.length
196
+ }
197
+ for (const e of flush()) yield e
198
+ }
199
+
200
+ // The polished "signed commit" detail view — the verify banner, the
201
+ // covered message(s), and the rehydrated value at HEAD. Shared between
202
+ // the repo view (rendered for the currently-selected commit, below the
203
+ // commit selector) and the at-view's SIGNATURE branch (when you've
204
+ // drilled into a sig directly).
205
+ function signedCommitDetail (repo, keyHex, sigAddress) {
206
+ let decoded
207
+ try { decoded = repo.decode(sigAddress) } catch { return h`<div class="empty">decode error</div>` }
208
+ const chunk = repo.resolve(sigAddress)
209
+ const chunkLen = chunk.length
210
+ const signedTo = sigAddress - chunkLen
211
+ const sigChunkStart = sigAddress - chunkLen + 1
212
+ const covered = commitsCoveredBySignature(repo, decoded.address, signedTo)
213
+ const head = covered[0]
214
+ return h`
215
+ <div class="signed-commit-banner">
216
+ <span class="signed-label">signed commit</span>
217
+ ${() => {
218
+ dep()
219
+ const status = verifyStatus(repo, keyHex, decoded, sigAddress)
220
+ const label = status === 'valid' ? 'verified — bytes match this repo’s public key'
221
+ : status === 'invalid' ? 'NOT VERIFIED — bytes do not match the repo key'
222
+ : status === 'pending' ? 'verifying…'
223
+ : `error: ${status?.error ?? 'unknown'}`
224
+ return h`${verifyBadge(status)} <span class="dim">${label}</span>`
225
+ }}
226
+ </div>
227
+ ${covered.length === 0
228
+ ? h`<div class="empty">this signature covers no commits (would only happen if a sign produced over a non-commit range)</div>`
229
+ : h`
230
+ <h3>${covered.length === 1 ? 'message' : `${covered.length} messages (batched into one signature)`}</h3>
231
+ ${covered.map(c => h`
232
+ <div class="commit-card" data-key=${`cc${c.address}`}>
233
+ <div class="commit-msg">${c.message || h`<span class="dim">(no message)</span>`}</div>
234
+ <div class="commit-meta dim">
235
+ <span>${fmtDate(c.date)}</span>
236
+ <span> · commit chunk <a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${c.address}>@${c.address}</a></span>
237
+ <span> · value <a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${c.dataAddress}>@${c.dataAddress}</a></span>
238
+ </div>
239
+ </div>
240
+ `)}
241
+ ${head ? (() => {
242
+ let value
243
+ try { value = repo.decode(head.dataAddress) } catch { value = undefined }
244
+ return h`
245
+ <h3>value at this point</h3>
246
+ <pre class="value">${value === undefined ? '(decode error)' : safeJSON(value)}</pre>
247
+ `
248
+ })() : null}
249
+ `}
250
+ <h3>signature</h3>
251
+ <table class="kv">
252
+ <tbody>
253
+ <tr>
254
+ <td>covers</td>
255
+ <td><a class="addr-link" data-action="open-at"
256
+ data-keyhex=${keyHex} data-addr=${decoded.address}
257
+ >@${decoded.address}</a> through @${signedTo} (${signedTo - decoded.address + 1} bytes)</td>
258
+ </tr>
259
+ <tr>
260
+ <td>sig chunk</td>
261
+ <td class="mono">@${sigChunkStart}…@${sigAddress} (${chunkLen} bytes)</td>
262
+ </tr>
263
+ <tr><td>bytes</td><td class="mono">${truncHex(decoded.compactRawBytes, 32)}</td></tr>
264
+ </tbody>
265
+ </table>
266
+ `
267
+ }
268
+
269
+ // Find the commits (newest-first) covered by a particular signature. Used
270
+ // by the at-view's SIGNATURE branch to assemble the "this is what you were
271
+ // looking for" polished view from a sig address alone.
272
+ function commitsCoveredBySignature (repo, signedFrom, signedTo) {
273
+ const commits = []
274
+ let addr = signedTo
275
+ while (addr >= signedFrom) {
276
+ const code = repo.resolve(addr)
277
+ if (!code || !code.length) break
278
+ const type = repo.footerToCodec[code.at(-1)]?.type
279
+ if (type === 'OBJECT') {
280
+ let value
281
+ try { value = repo.decode(addr) } catch { value = null }
282
+ if (isCommitShape(value)) {
283
+ commits.push({
284
+ address: addr,
285
+ message: value.message,
286
+ date: value.date,
287
+ dataAddress: value.dataAddress,
288
+ parent: value.parent
289
+ })
290
+ }
291
+ }
292
+ addr -= code.length
293
+ }
294
+ return commits
295
+ }
296
+
297
+ // Decode the value at an address but treat object/array as REFS (children
298
+ // are addresses, not decoded recursively). For primitives, returns the
299
+ // decoded value directly.
300
+ function valueAndChildren (repo, address) {
301
+ const code = repo.resolve(address)
302
+ const codecType = repo.footerToCodec[code.at(-1)]?.type
303
+ const refs = repo.asRefs(address)
304
+ // refs is either an object/array of addresses or just the address itself for primitives
305
+ return { codecType, refs, decoded: repo.decode(address) }
306
+ }
307
+
308
+ // ── Views ─────────────────────────────────────────────────────────────────
309
+
310
+ function RegistryView () {
311
+ return h`
312
+ <h2>repos <span class="dim">${() => { dep(); return `(${[...registry].length})` }}</span></h2>
313
+ ${() => {
314
+ dep()
315
+ const rows = []
316
+ for (const [keyHex, repo] of registry) {
317
+ const last = repo.lastCommit
318
+ rows.push(h`
319
+ <div class="row" data-key=${keyHex} data-action="open-repo">
320
+ <span class="mono">${truncKey(keyHex)}</span>
321
+ <span class="when">${last ? fmtDate(last.date) : '(no commits)'}</span>
322
+ <span class="msg dim">${last?.message || ''}</span>
323
+ </div>
324
+ `)
325
+ }
326
+ return rows.length ? rows : h`<div class="empty">waiting for repos…</div>`
327
+ }}
328
+ `
329
+ }
330
+
331
+ // Resolve the symbolic HEAD address to the most-recent sig chunk's
332
+ // address. Returns undefined if the repo has no signatures yet.
333
+ function resolveHead (repo) {
334
+ let walk = repo.byteLength - 1
335
+ while (walk >= 0) {
336
+ const code = repo.resolve(walk)
337
+ if (!code || !code.length) break
338
+ if (repo.footerToCodec[code.at(-1)]?.type === 'SIGNATURE') return walk
339
+ walk -= code.length
340
+ }
341
+ return undefined
342
+ }
343
+
344
+ // Commit selector dropdown — used at the top of any sig at-view. The
345
+ // summary is the currently-selected (= currently-viewed) signed commit
346
+ // styled as a head-card; the body lists every signed commit with the
347
+ // current one marked. Picking a different one (data-action=select-commit)
348
+ // navigates to /at/<sigAddress> via the URL.
349
+ function commitSelectorSection (repo, keyHex, currentSigAddr) {
350
+ const entries = [...signedCommits(repo)].filter(e => e.kind === 'signedCommit')
351
+ if (!entries.length) return null
352
+ const tagFor = i => i === 0 ? 'HEAD' : `HEAD-${i}`
353
+ const signedRow = (e, tag, { asSummary = false, isSelected = false } = {}) => {
354
+ const c0 = e.commits[0]
355
+ const headline = e.commits.length === 0
356
+ ? h`<span class="dim">(sig with no covered commits)</span>`
357
+ : e.commits.length === 1
358
+ ? (c0.message || h`<span class="dim">(no message)</span>`)
359
+ : h`<span class="dim">${e.commits.length}× </span>${c0?.message || ''}`
360
+ const cls = ['row', 'signed-commit',
361
+ asSummary ? 'head-card' : null,
362
+ isSelected ? 'selected' : null]
363
+ const action = asSummary ? null : 'select-commit'
364
+ return h`
365
+ <div class=${cls}
366
+ data-key=${`sc${e.sigAddress}`}
367
+ data-action=${action}
368
+ data-keyhex=${keyHex} data-addr=${e.sigAddress}>
369
+ <span class="kind">${tag} ${() => { dep(); return verifyBadge(verifyStatus(repo, keyHex, repo.decode(e.sigAddress), e.sigAddress)) }}</span>
370
+ <span class="msg">${headline}</span>
371
+ <span class="when">${c0 ? fmtDate(c0.date) : ''}</span>
372
+ <span class="mono dim">@${e.sigAddress}</span>
373
+ </div>`
374
+ }
375
+ const selectedIdx = entries.findIndex(e => e.sigAddress === currentSigAddr)
376
+ const selected = selectedIdx >= 0 ? entries[selectedIdx] : entries[0]
377
+ return h`
378
+ <details class="commit-selector" data-key=${`selector-${keyHex}`}>
379
+ <summary>${signedRow(selected, tagFor(selectedIdx >= 0 ? selectedIdx : 0), { asSummary: true })}</summary>
380
+ ${entries.length > 1 ? h`
381
+ <div class="dropdown-body">
382
+ ${entries.map((e, i) => signedRow(e, tagFor(i), { isSelected: e === selected }))}
383
+ </div>
384
+ ` : null}
385
+ </details>
386
+ `
387
+ }
388
+
389
+ // Unsigned commits + the repo-wide "other storage chunks" list. Shown
390
+ // below the polished detail on sig at-views and on the no-HEAD page,
391
+ // so the repo's full inventory is always one click away.
392
+ function repoExtras (repo, keyHex) {
393
+ const entries = [...signedCommits(repo)]
394
+ const unsigned = entries.filter(e => e.kind === 'unsignedCommit')
395
+ const others = entries.filter(e => e.kind === 'other')
396
+ return h`
397
+ ${unsigned.length ? h`
398
+ <h3>unsigned <span class="dim">(${unsigned.length} — sign in flight or pending)</span></h3>
399
+ ${unsigned.map(e => h`
400
+ <div class="row unsigned-commit" data-key=${`u${e.address}`} data-action="open-at"
401
+ data-keyhex=${keyHex} data-addr=${e.address}>
402
+ <span class="kind">unsigned</span>
403
+ <span class="msg">${e.message || h`<span class="dim">(no message)</span>`}</span>
404
+ <span class="when">${fmtDate(e.date)}</span>
405
+ <span class="mono dim">@${e.address}</span>
406
+ </div>
407
+ `)}
408
+ ` : null}
409
+ ${others.length ? h`
410
+ <details class="other-storage">
411
+ <summary>storage chunks <span class="dim">(${others.length}) — the chunks underneath</span></summary>
412
+ <table class="kv clickable">
413
+ <tbody>
414
+ ${others.map(e => h`
415
+ <tr data-key=${`o${e.address}`} data-action="open-at"
416
+ data-keyhex=${keyHex} data-addr=${e.address}>
417
+ <td class="mono dim">${e.codecType}</td>
418
+ <td>${(() => { try { return previewValue(repo.decode(e.address)) } catch { return '' } })()}</td>
419
+ <td class="mono dim">@${e.address}</td>
420
+ </tr>
421
+ `)}
422
+ </tbody>
423
+ </table>
424
+ </details>
425
+ ` : null}
426
+ `
427
+ }
428
+
429
+ function AtView ({ keyHex, address }) {
430
+ return h`
431
+ <a class="back" data-action="back-registry">← all repos</a>
432
+ <div class="keyfull">
433
+ <a class="repo-link" data-action="back-repo" data-keyhex=${keyHex}>${truncKey(keyHex)}</a>
434
+ <span class="dim"> @ ${address}</span>
435
+ </div>
436
+ ${() => {
437
+ dep()
438
+ const repo = registry.get(keyHex)
439
+ if (!repo) return h`<div class="empty">opening…</div>`
440
+
441
+ // Resolve HEAD (symbolic) to the most-recent sig address. If the
442
+ // repo has no sigs yet, render a useful "no HEAD" page that still
443
+ // surfaces unsigned commits and storage chunks.
444
+ let resolvedAddr = address
445
+ if (address === 'HEAD') {
446
+ resolvedAddr = resolveHead(repo)
447
+ if (resolvedAddr === undefined) {
448
+ return h`
449
+ <h2>at HEAD <span class="dim">(no signed commits yet)</span></h2>
450
+ <div class="empty">this repo hasn't signed anything yet — once it does, HEAD will point to the most-recent signed commit and you'll land here automatically.</div>
451
+ ${repoExtras(repo, keyHex)}
452
+ `
453
+ }
454
+ }
455
+ if (resolvedAddr >= repo.byteLength) return h`<div class="empty">loading…</div>`
456
+
457
+ let info
458
+ try { info = valueAndChildren(repo, resolvedAddr) }
459
+ catch (e) { return h`<pre class="value">decode error: ${e.message}</pre>` }
460
+
461
+ const { codecType, refs, decoded } = info
462
+ const isCommit = isCommitShape(decoded)
463
+ const isSig = codecType === 'SIGNATURE'
464
+
465
+ // Tabs are part of the page content (not the static header) so a
466
+ // sig at-view can render the commit selector ABOVE the tabs.
467
+ const tabs = h`
468
+ <nav class="tabs">
469
+ <a class=${() => { dep(); return ['tab', atTab === 'value' ? 'active' : null] }}
470
+ data-action="set-tab" data-tab="value">value</a>
471
+ <a class=${() => { dep(); return ['tab', atTab === 'storage' ? 'active' : null] }}
472
+ data-action="set-tab" data-tab="storage">storage</a>
473
+ </nav>
474
+ `
475
+ const selector = isSig ? commitSelectorSection(repo, keyHex, resolvedAddr) : null
476
+
477
+ // Storage tab: spatial view of where this chunk lives in the byte
478
+ // stream + outgoing references + this chunk's bytes + incoming
479
+ // referrers. The chunk graph from this chunk's perspective.
480
+ if (atTab === 'storage') {
481
+ return h`
482
+ ${selector}
483
+ ${tabs}
484
+ ${byteStreamSection(repo, keyHex, resolvedAddr)}
485
+ ${outgoingReferencesSection(repo, keyHex, resolvedAddr)}
486
+ ${rawChunkSection(repo, resolvedAddr)}
487
+ ${referrersSection(repo, keyHex, resolvedAddr)}
488
+ `
489
+ }
490
+
491
+ // Value tab — branches by codec.
492
+ if (isCommit) {
493
+ const parentDataAddr = decoded.parent !== undefined
494
+ ? safeGet(() => repo.decode(decoded.parent)?.dataAddress)
495
+ : undefined
496
+ const changes = parentDataAddr !== undefined
497
+ ? [...changedPaths(repo, parentDataAddr, decoded.dataAddress)]
498
+ : null
499
+ return h`
500
+ ${tabs}
501
+ <div class="dim">codec: ${codecType} · this is a commit</div>
502
+ <table class="kv">
503
+ <tbody>
504
+ <tr><td>message</td><td>${decoded.message || h`<span class="dim">(empty)</span>`}</td></tr>
505
+ <tr><td>date</td><td>${fmtDate(decoded.date)}</td></tr>
506
+ <tr>
507
+ <td>dataAddress</td>
508
+ <td><a class="addr-link" data-action="open-at"
509
+ data-keyhex=${keyHex} data-addr=${decoded.dataAddress}
510
+ >@${decoded.dataAddress}</a></td>
511
+ </tr>
512
+ <tr>
513
+ <td>parent</td>
514
+ <td>${decoded.parent === undefined
515
+ ? h`<span class="dim">(none — first commit)</span>`
516
+ : h`<a class="addr-link" data-action="open-at"
517
+ data-keyhex=${keyHex} data-addr=${decoded.parent}
518
+ >@${decoded.parent}</a>`}</td>
519
+ </tr>
520
+ </tbody>
521
+ </table>
522
+ ${changes
523
+ ? h`
524
+ <h3>changed paths <span class="dim">(${changes.length})</span></h3>
525
+ ${changes.length
526
+ ? h`<ul class="paths">${changes.map(p => h`<li class="mono">${p.length === 0 ? '/' : p.join('.')}</li>`)}</ul>`
527
+ : h`<div class="dim">(no path-level changes — same dataAddress)</div>`}
528
+ `
529
+ : null}
530
+ <h3>rehydrated</h3>
531
+ <pre class="value">${safeJSON(decoded)}</pre>
532
+ `
533
+ }
534
+
535
+ // Duple: explain what this tree-node IS, then show its two children.
536
+ if (codecType === 'DUPLE') {
537
+ return h`
538
+ ${tabs}
539
+ <div class="dim">codec: DUPLE</div>
540
+ <p class="explainer">
541
+ A <strong>Duple</strong> is a 2-tuple — the building block streamo uses
542
+ to balance binary trees of OBJECT entries and ARRAY elements. Each Duple
543
+ holds two slots; the slots are either values (a leaf) or other Duples
544
+ (an interior tree node). They're how content-addressing scales to
545
+ larger objects/arrays without rewriting the whole structure on every
546
+ small change — siblings keep their addresses, and dedup happens at
547
+ every level of the tree.
548
+ </p>
549
+ <table class="kv">
550
+ <tbody>
551
+ <tr><td class="mono">v[0]</td><td>${previewValue(decoded.v[0])}</td></tr>
552
+ <tr><td class="mono">v[1]</td><td>${previewValue(decoded.v[1])}</td></tr>
553
+ </tbody>
554
+ </table>
555
+ `
556
+ }
557
+
558
+ // Signature: the polished "signed commit" view + the unsigned/storage
559
+ // listing below. Shared with the storage tab's chunk-detail view.
560
+ if (isSig) {
561
+ return h`
562
+ ${selector}
563
+ ${tabs}
564
+ ${signedCommitDetail(repo, keyHex, resolvedAddr)}
565
+ ${repoExtras(repo, keyHex)}
566
+ <div class="dim" style="margin-top: 0.5rem;">switch to the <strong>storage</strong> tab above to see the raw chunk bytes, outgoing references, and what else points at this address.</div>
567
+ `
568
+ }
569
+
570
+ // Object/array: clickable children with their addresses.
571
+ if (refs && typeof refs === 'object') {
572
+ const isArray = Array.isArray(refs)
573
+ const entries = isArray
574
+ ? refs.map((addr, i) => [String(i), addr])
575
+ : Object.entries(refs)
576
+ if (entries.length === 0) {
577
+ return h`
578
+ ${tabs}
579
+ <div class="dim">codec: ${codecType}</div>
580
+ <div class="empty">${isArray ? '[]' : '{}'}</div>
581
+ `
582
+ }
583
+ return h`
584
+ ${tabs}
585
+ <div class="dim">codec: ${codecType}${isArray ? ` · length ${entries.length}` : ''}</div>
586
+ <table class="kv clickable">
587
+ <tbody>
588
+ ${entries.map(([k, childAddr]) => {
589
+ // asRefs is mutation-impossible, so it returns undefined for
590
+ // inline children that don't have a separate chunk address.
591
+ // Show those non-clickably with the decoded value pulled from
592
+ // the parent.
593
+ if (childAddr === undefined) {
594
+ const inlineValue = isArray ? decoded[+k] : decoded[k]
595
+ return h`
596
+ <tr>
597
+ <td class="mono">${k}</td>
598
+ <td>${previewValue(inlineValue)}</td>
599
+ <td class="dim">(inline)</td>
600
+ </tr>
601
+ `
602
+ }
603
+ let preview = ''
604
+ try { preview = previewValue(repo.decode(childAddr)) }
605
+ catch { preview = '(error)' }
606
+ return h`
607
+ <tr data-key=${k} data-action="open-at"
608
+ data-keyhex=${keyHex} data-addr=${childAddr}>
609
+ <td class="mono">${k}</td>
610
+ <td>${preview}</td>
611
+ <td class="mono dim">@${childAddr}</td>
612
+ </tr>
613
+ `
614
+ })}
615
+ </tbody>
616
+ </table>
617
+ <h3>rehydrated</h3>
618
+ <pre class="value">${safeJSON(decoded)}</pre>
619
+ `
620
+ }
621
+
622
+ // Primitive: just show it.
623
+ return h`
624
+ ${tabs}
625
+ <div class="dim">codec: ${codecType}</div>
626
+ <pre class="value">${safeJSON(decoded)}</pre>
627
+ `
628
+ }}
629
+ `
630
+ }
631
+
632
+ // Hex dump of the chunk at this address — the actual bytes that live in the
633
+ // streamo for this value. For commits we also include this so you can see
634
+ // the literal commit-record bytes.
635
+ function rawChunkSection (repo, address) {
636
+ let bytes
637
+ try { bytes = repo.resolve(address) }
638
+ catch { return null }
639
+ if (!bytes || !bytes.length) return null
640
+ return h`
641
+ <h3>chunk bytes <span class="dim">(${bytes.length} bytes ending @${address})</span></h3>
642
+ <pre class="value mono">${hexDump(bytes)}</pre>
643
+ `
644
+ }
645
+
646
+ // Map a codec type to a visual category. Many distinct codecs map to a
647
+ // shared category so the byte-stream stripe stays readable: commits (the
648
+ // narrative anchors), signatures (attestations), composite values, the
649
+ // Duple tree-scaffolding, strings, bytes, numbers, etc.
650
+ function codecCategory (type) {
651
+ switch (type) {
652
+ case 'SIGNATURE': return 'sig'
653
+ case 'OBJECT': case 'EMPTY_OBJECT': case 'ARRAY': case 'EMPTY_ARRAY': return 'composite'
654
+ case 'DUPLE': return 'duple'
655
+ case 'STRING': case 'EMPTY_STRING': return 'string'
656
+ case 'WORD': case 'UINT8ARRAY': case 'EMPTY_UINT8ARRAY': return 'bytes'
657
+ case 'DATE': case 'FLOAT64': case 'UINT7': return 'num'
658
+ case 'VARIABLE': return 'var'
659
+ default: return 'other'
660
+ }
661
+ }
662
+
663
+ // Byte stream as a color-coded SVG strip — every chunk is a rect, color
664
+ // coded by codec category. Modestly zoomed so even 1-byte chunks have a
665
+ // clickable width; horizontally scrollable, click-drag-to-pan inside the
666
+ // strip (cursor: grab/grabbing). First render auto-scrolls to HEAD (the
667
+ // newest content, at the right) and stays pinned there if you haven't
668
+ // dragged off it — so a live stream "follows" the newest activity. The
669
+ // signed-commits dropdown above is for jumping to a known commit; this
670
+ // strip is for poking around between them.
671
+ function byteStreamSection (repo, keyHex, currentAddress) {
672
+ const chunks = []
673
+ let addr = repo.byteLength - 1
674
+ while (addr >= 0) {
675
+ const code = repo.resolve(addr)
676
+ if (!code || !code.length) break
677
+ const codec = repo.footerToCodec[code.at(-1)]
678
+ chunks.unshift({
679
+ address: addr,
680
+ start: addr - code.length + 1,
681
+ length: code.length,
682
+ codecType: codec?.type || '?'
683
+ })
684
+ addr -= code.length
685
+ }
686
+ if (!chunks.length) return null
687
+
688
+ // Mark commit addresses by walking history once — cheap, lets commits
689
+ // appear as their own visual category instead of getting lumped in with
690
+ // generic OBJECTs.
691
+ const commitAddrs = new Set()
692
+ let walkAddr = repo.valueAddress
693
+ while (walkAddr !== undefined && walkAddr >= 0) {
694
+ let commit
695
+ try { commit = repo.decode(walkAddr) } catch { break }
696
+ if (!commit || typeof commit.message !== 'string' || !(commit.date instanceof Date)) break
697
+ commitAddrs.add(walkAddr)
698
+ walkAddr = commit.parent
699
+ }
700
+
701
+ const total = repo.byteLength
702
+ // Each chunk gets max(MIN_PX, proportional zoomed width). At ZOOM=2 the
703
+ // strip is roughly 2x viewport-wide for typical repos — enough to
704
+ // scroll/drag through without losing spatial sense, and MIN_PX keeps
705
+ // even 1-byte chunks clickable.
706
+ const ZOOM = 2
707
+ const MIN_PX = 8
708
+ const H = 36
709
+ const zoomedW = 1200 * ZOOM
710
+ let cursorX = 0
711
+ const layout = chunks.map(c => {
712
+ const propW = (c.length / total) * zoomedW
713
+ const w = Math.max(MIN_PX, propW)
714
+ const item = { ...c, x: cursorX, w }
715
+ cursorX += w
716
+ return item
717
+ })
718
+ const stripW = cursorX
719
+ return h`
720
+ <h3>byte stream <span class="dim">(${total} bytes · ${chunks.length} chunks)</span></h3>
721
+ <div class="byte-map-legend">
722
+ <span class="cat-commit">commit</span>
723
+ <span class="cat-sig">sig</span>
724
+ <span class="cat-composite">object/array</span>
725
+ <span class="cat-duple">duple</span>
726
+ <span class="cat-string">string</span>
727
+ <span class="cat-bytes">bytes</span>
728
+ <span class="cat-num">num</span>
729
+ <span class="cat-var">var</span>
730
+ </div>
731
+ <div class="byte-strip-container" data-key=${`strip-${keyHex}`}>
732
+ <svg class="byte-map byte-strip" width=${stripW} height=${H} viewBox=${`0 0 ${stripW} ${H}`}>
733
+ ${layout.map(c => {
734
+ const cat = commitAddrs.has(c.address) ? 'commit' : codecCategory(c.codecType)
735
+ const cls = ['chunk', `cat-${cat}`, c.address === currentAddress ? 'current' : null]
736
+ return h`<rect
737
+ class=${cls}
738
+ x=${c.x} y="0" width=${c.w} height=${H}
739
+ data-action="open-at"
740
+ data-keyhex=${keyHex}
741
+ data-addr=${c.address}
742
+ ><title>${c.codecType} @${c.address} (${c.length} bytes)</title></rect>`
743
+ })}
744
+ </svg>
745
+ </div>
746
+ `
747
+ }
748
+
749
+ // Outgoing references — what THIS chunk points to in the chunk graph (as
750
+ // opposed to "referenced by", which is what points to this chunk). Walks
751
+ // the codec's parts via repo.directReferences. Codec-by-codec — exposes
752
+ // the storage chain so e.g. STRING → UINT8ARRAY → DUPLE → DUPLE → … → WORD
753
+ // is browsable one click at a time.
754
+ function outgoingReferencesSection (repo, keyHex, address) {
755
+ const refs = repo.directReferences(address)
756
+ if (!refs.length) return null
757
+ return h`
758
+ <h3>references <span class="dim">(${refs.length})</span></h3>
759
+ <table class="kv clickable">
760
+ <tbody>
761
+ ${refs.map((childAddr, i) => {
762
+ let codecType = '?'
763
+ let preview = ''
764
+ try {
765
+ const childCode = repo.resolve(childAddr)
766
+ codecType = repo.footerToCodec[childCode.at(-1)]?.type || '?'
767
+ preview = previewValue(repo.decode(childAddr))
768
+ } catch { preview = '(error)' }
769
+ return h`
770
+ <tr data-key=${`out${i}@${childAddr}`} data-action="open-at"
771
+ data-keyhex=${keyHex} data-addr=${childAddr}>
772
+ <td class="mono dim">${codecType}</td>
773
+ <td>${preview}</td>
774
+ <td class="mono dim">@${childAddr}</td>
775
+ </tr>
776
+ `
777
+ })}
778
+ </tbody>
779
+ </table>
780
+ `
781
+ }
782
+
783
+ // "Referenced by" — walks up the Duple tree-scaffolding to find the chunks
784
+ // that USE this address in a user-meaningful sense (OBJECT, ARRAY, VARIABLE,
785
+ // SIGNATURE, etc.). Internal Duples are skipped — they're how the codec
786
+ // builds balanced trees, not where the user thinks about the data living.
787
+ //
788
+ // Each row shows: codec, a one-line preview of the value, the address, and
789
+ // — if more than one Duple path leads to the same ancestor — a path count.
790
+ function referrersSection (repo, keyHex, address) {
791
+ const index = buildReferrerIndex(repo)
792
+ const refs = findUserReferrers(repo, address, index)
793
+ if (!refs.length) {
794
+ return h`
795
+ <h3>referenced by <span class="dim">(0)</span></h3>
796
+ <div class="dim">no chunks in this repo reference this value</div>
797
+ `
798
+ }
799
+ return h`
800
+ <h3>referenced by <span class="dim">(${refs.length} ${refs.length === 1 ? 'place' : 'places'})</span></h3>
801
+ <table class="kv clickable">
802
+ <tbody>
803
+ ${refs.map(r => {
804
+ let preview = ''
805
+ try { preview = previewValue(repo.decode(r.address)) }
806
+ catch { preview = '(error)' }
807
+ return h`
808
+ <tr data-key=${`r${r.address}`} data-action="open-at"
809
+ data-keyhex=${keyHex} data-addr=${r.address}>
810
+ <td class="mono dim">${r.codecType || '?'}${r.count > 1 ? ` ×${r.count}` : ''}</td>
811
+ <td>${preview}</td>
812
+ <td class="mono dim">@${r.address}</td>
813
+ </tr>
814
+ `
815
+ })}
816
+ </tbody>
817
+ </table>
818
+ `
819
+ }
820
+
821
+ // Detect a Duple instance — codecs.js doesn't export the class so we have to
822
+ // duck-type. A Duple is an object whose only own property is `v`, a length-2
823
+ // array. (Used so we can render Duples as `[a, b]` rather than `{…} (1)`.)
824
+ function isDuple (v) {
825
+ return v && typeof v === 'object' && Array.isArray(v.v) && v.v.length === 2 && Object.keys(v).length === 1
826
+ }
827
+
828
+ function previewValue (v, depth = 0) {
829
+ if (v == null) return String(v)
830
+ if (typeof v === 'string') return v.length > 60 ? JSON.stringify(v.slice(0, 60)) + '…' : JSON.stringify(v)
831
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v)
832
+ if (v instanceof Date) return v.toISOString()
833
+ if (v instanceof Uint8Array) return `Uint8Array(${v.length})`
834
+ if (isDuple(v)) {
835
+ if (depth > 2) return 'Duple(…)'
836
+ return `Duple(${previewValue(v.v[0], depth + 1)}, ${previewValue(v.v[1], depth + 1)})`
837
+ }
838
+ if (Array.isArray(v)) return `[…] (${v.length})`
839
+ if (typeof v === 'object') return `{…} (${Object.keys(v).length})`
840
+ return String(v)
841
+ }
842
+
843
+ function safeGet (f) { try { return f() } catch { return undefined } }
844
+
845
+ // Build a child→parents index for the entire repo in one pass, so we can
846
+ // answer "who references address X?" in O(1) per query and walk up parent
847
+ // chains without re-scanning. Each entry maps a chunk's address to all the
848
+ // chunks that have it as a DIRECT child (via asRefs).
849
+ function buildReferrerIndex (repo) {
850
+ const index = new Map() // childAddr → [{ address, codecType }]
851
+ let addr = repo.byteLength - 1
852
+ while (addr >= 0) {
853
+ const code = repo.resolve(addr)
854
+ if (!code || !code.length) break
855
+ let refs
856
+ try { refs = repo.asRefs(addr) } catch { refs = null }
857
+ let childAddrs = []
858
+ if (Array.isArray(refs)) {
859
+ childAddrs = refs.filter(x => typeof x === 'number')
860
+ } else if (refs && typeof refs === 'object') {
861
+ if (Array.isArray(refs.v)) childAddrs = refs.v.filter(x => typeof x === 'number')
862
+ else childAddrs = Object.values(refs).filter(x => typeof x === 'number')
863
+ }
864
+ if (childAddrs.length) {
865
+ const codec = repo.footerToCodec[code.at(-1)]
866
+ const entry = { address: addr, codecType: codec?.type }
867
+ for (const child of childAddrs) {
868
+ if (!index.has(child)) index.set(child, [])
869
+ index.get(child).push(entry)
870
+ }
871
+ }
872
+ addr -= code.length
873
+ }
874
+ return index
875
+ }
876
+
877
+ // Walk up parent chains via the index. Internal Duple nodes are tree
878
+ // scaffolding — the user-meaningful containers are OBJECT / ARRAY /
879
+ // VARIABLE / SIGNATURE / etc. For each path that hits a non-Duple
880
+ // ancestor, accumulate that ancestor with a count of how many paths
881
+ // reach it. (Same value referenced from N different places yields N
882
+ // distinct user-level ancestors.)
883
+ function findUserReferrers (repo, targetAddr, index) {
884
+ const result = new Map() // ancestorAddr → { address, codecType, count }
885
+ function walkUp (from) {
886
+ const refs = index.get(from) ?? []
887
+ for (const r of refs) {
888
+ if (r.codecType === 'DUPLE') {
889
+ walkUp(r.address)
890
+ } else {
891
+ const existing = result.get(r.address)
892
+ if (existing) existing.count++
893
+ else result.set(r.address, { ...r, count: 1 })
894
+ }
895
+ }
896
+ }
897
+ walkUp(targetAddr)
898
+ return [...result.values()].sort((a, b) => b.address - a.address) // newest first
899
+ }
900
+
901
+ // Backwards-compat: if anyone wanted the raw direct referrers (Duples and
902
+ // all), this still works.
903
+ function findReferrers (repo, targetAddr) {
904
+ return buildReferrerIndex(repo).get(targetAddr) ?? []
905
+ }
906
+
907
+ // ── Signature verification cache ──────────────────────────────────────────
908
+ //
909
+ // repo.verify(sig, publicKey) is async. Slots render synchronously, so we
910
+ // cache results keyed by (keyHex, sigChunkAddress) and kick off the async
911
+ // verify on first encounter. When it resolves, fire() so the slot re-runs
912
+ // and the badge flips from "verifying…" to ✓ / ✗.
913
+ //
914
+ // One verify per signature per page load (~sub-ms each for secp256k1).
915
+
916
+ const verifyCache = new Map() // `${keyHex}:${addr}` → 'pending' | 'valid' | 'invalid' | { error }
917
+
918
+ function verifyStatus (repo, keyHex, sig, sigAddress) {
919
+ const cacheKey = `${keyHex}:${sigAddress}`
920
+ if (verifyCache.has(cacheKey)) return verifyCache.get(cacheKey)
921
+ verifyCache.set(cacheKey, 'pending')
922
+ repo.verify(sig, hexToBytes(keyHex))
923
+ .then(valid => { verifyCache.set(cacheKey, valid ? 'valid' : 'invalid'); fire() })
924
+ .catch(e => { verifyCache.set(cacheKey, { error: e.message }); fire() })
925
+ return 'pending'
926
+ }
927
+
928
+ function verifyBadge (status) {
929
+ if (status === 'valid') return h`<span class="verify-badge valid" title="signature verified against repo's public key">✓</span>`
930
+ if (status === 'invalid') return h`<span class="verify-badge invalid" title="signature does NOT match repo's public key">✗</span>`
931
+ if (status === 'pending') return h`<span class="verify-badge pending" title="verifying…">…</span>`
932
+ return h`<span class="verify-badge error" title=${status?.error || 'verification error'}>⚠</span>`
933
+ }
934
+
935
+ // Hex dump of a chunk's raw bytes. Truncates at maxLen so a giant value
936
+ // chunk doesn't blow up the page.
937
+ function hexDump (bytes, maxLen = 256) {
938
+ const lines = []
939
+ const len = Math.min(bytes.length, maxLen)
940
+ for (let i = 0; i < len; i += 16) {
941
+ const offset = i.toString(16).padStart(4, '0')
942
+ const slice = bytes.subarray(i, Math.min(i + 16, len))
943
+ const hex = Array.from(slice).map(b => b.toString(16).padStart(2, '0')).join(' ')
944
+ const ascii = Array.from(slice).map(b => (b >= 0x20 && b < 0x7f) ? String.fromCharCode(b) : '·').join('')
945
+ lines.push(`${offset} ${hex.padEnd(48)} ${ascii}`)
946
+ }
947
+ if (bytes.length > maxLen) lines.push(`… (${bytes.length - maxLen} more bytes)`)
948
+ return lines.join('\n')
949
+ }
950
+
951
+ // ── Mount ─────────────────────────────────────────────────────────────────
952
+
953
+ const appEl = document.getElementById('app')
954
+
955
+ // Wrap each view in a data-keyed <section> so mount's tag-pool recycling
956
+ // doesn't pull stale elements from one view into another. Without this,
957
+ // switching from registry to an at-view would recycle the registry's
958
+ // <h2> and keep its old text children (patchElement only updates attrs).
959
+ // The data-key changes whenever the view's identity changes (kind + the
960
+ // params that affect rendering), forcing a fresh mount.
961
+ mount(h`${() => {
962
+ dep()
963
+ switch (view.kind) {
964
+ case 'registry': return h`<section class="view" data-key="view-registry">${RegistryView()}</section>`
965
+ case 'at': return h`<section class="view" data-key=${`view-at-${view.keyHex}-${view.address}`}>${AtView({ keyHex: view.keyHex, address: view.address })}</section>`
966
+ default: return h`<div class="empty">?</div>`
967
+ }
968
+ }}`, appEl, recaller)
969
+
970
+ // ── Click delegation ──────────────────────────────────────────────────────
971
+
972
+ let suppressClickUntil = 0
973
+ appEl.addEventListener('click', e => {
974
+ // Suppress the click that fires at the end of a drag-to-pan, so dragging
975
+ // doesn't accidentally navigate to a chunk under the pointer when the
976
+ // user releases.
977
+ if (Date.now() < suppressClickUntil) return
978
+ const el = e.target.closest('[data-action]')
979
+ if (!el) return
980
+ switch (el.dataset.action) {
981
+ case 'open-repo': return go({ kind: 'at', keyHex: el.dataset.key, address: 'HEAD' })
982
+ case 'open-at': return go({ kind: 'at', keyHex: el.dataset.keyhex, address: +el.dataset.addr })
983
+ case 'back-registry': return go({ kind: 'registry' })
984
+ case 'back-repo': return go({ kind: 'at', keyHex: el.dataset.keyhex, address: 'HEAD' })
985
+ case 'set-tab': atTab = el.dataset.tab; return fire()
986
+ case 'select-commit': {
987
+ // Picking a commit is just navigation — go to /at/<sigAddress>.
988
+ // Close the dropdown imperatively so the new view renders with
989
+ // the selector collapsed (matches native <select> behavior).
990
+ el.closest('details.commit-selector')?.removeAttribute('open')
991
+ return go({ kind: 'at', keyHex: el.dataset.keyhex, address: +el.dataset.addr })
992
+ }
993
+ }
994
+ })
995
+
996
+ // ── Byte-strip drag-to-pan + auto-scroll-to-HEAD ─────────────────────────
997
+
998
+ // On first render of a strip, scroll to the right edge (HEAD = newest
999
+ // content). On subsequent renders, only re-pin if the user is already at
1000
+ // or near the right edge — so a live stream "follows" without dragging
1001
+ // you back if you've scrolled into history.
1002
+ function syncByteStrips () {
1003
+ for (const container of appEl.querySelectorAll('.byte-strip-container')) {
1004
+ const visible = container.clientWidth || 1
1005
+ const atRight = container.scrollLeft + visible >= container.scrollWidth - 8
1006
+ if (!container.dataset.pinned || atRight) {
1007
+ container.dataset.pinned = '1'
1008
+ container.scrollLeft = container.scrollWidth
1009
+ }
1010
+ }
1011
+ }
1012
+
1013
+ // Click-drag-to-pan inside the detail strip. Threshold of 4px before
1014
+ // treating a pointerdown as a drag — under that, fall through to the
1015
+ // regular click handler so chunk-clicks still navigate.
1016
+ let dragState = null
1017
+ appEl.addEventListener('pointerdown', e => {
1018
+ if (e.button !== undefined && e.button !== 0) return
1019
+ const container = e.target?.closest?.('.byte-strip-container')
1020
+ if (!container) return
1021
+ dragState = {
1022
+ container,
1023
+ pointerId: e.pointerId,
1024
+ startX: e.clientX,
1025
+ startScroll: container.scrollLeft,
1026
+ dragging: false
1027
+ }
1028
+ })
1029
+ appEl.addEventListener('pointermove', e => {
1030
+ if (!dragState || e.pointerId !== dragState.pointerId) return
1031
+ const dx = e.clientX - dragState.startX
1032
+ if (!dragState.dragging) {
1033
+ if (Math.abs(dx) < 4) return
1034
+ dragState.dragging = true
1035
+ dragState.container.classList.add('dragging')
1036
+ try { dragState.container.setPointerCapture(e.pointerId) } catch {}
1037
+ }
1038
+ dragState.container.scrollLeft = dragState.startScroll - dx
1039
+ e.preventDefault()
1040
+ })
1041
+ function endDrag () {
1042
+ if (!dragState) return
1043
+ if (dragState.dragging) {
1044
+ dragState.container.classList.remove('dragging')
1045
+ try { dragState.container.releasePointerCapture?.(dragState.pointerId) } catch {}
1046
+ suppressClickUntil = Date.now() + 100
1047
+ }
1048
+ dragState = null
1049
+ }
1050
+ appEl.addEventListener('pointerup', endDrag)
1051
+ appEl.addEventListener('pointercancel', endDrag)
1052
+
1053
+ // Cross-highlight: hovering any element with data-addr highlights the
1054
+ // matching chunk in the byte-map. References and referrers light up the
1055
+ // chunk's position in the stream so you can SEE where it lives. If the
1056
+ // hover came from somewhere other than the strip itself, smooth-scroll
1057
+ // the matching chunk into view inside any byte-strip-container —
1058
+ // otherwise hover-elsewhere can highlight chunks that are off-screen.
1059
+ appEl.addEventListener('mouseover', e => {
1060
+ const el = e.target.closest('[data-addr]')
1061
+ if (!el) return
1062
+ const addr = el.dataset.addr
1063
+ const matches = appEl.querySelectorAll(`.byte-map .chunk[data-addr="${addr}"]`)
1064
+ matches.forEach(c => c.classList.add('hovered'))
1065
+ if (!el.closest('.byte-strip-container')) {
1066
+ matches.forEach(c => {
1067
+ if (c.closest('.byte-strip-container')) {
1068
+ c.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
1069
+ }
1070
+ })
1071
+ }
1072
+ })
1073
+ appEl.addEventListener('mouseout', e => {
1074
+ const el = e.target.closest('[data-addr]')
1075
+ if (!el) return
1076
+ appEl.querySelectorAll('.byte-map .chunk.hovered')
1077
+ .forEach(c => c.classList.remove('hovered'))
1078
+ })