@dtudury/streamo 3.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.
@@ -1,9 +1,17 @@
1
- // streamo explorer — read-only registry / repo / address browser.
1
+ // streamo explorer — read-only registry / address browser.
2
2
  //
3
- // Three views, navigated by URL hash:
4
- // #/ — registry list
5
- // #/repo/<keyHex> chunks (commits + signatures) in a repo
6
- // #/repo/<keyHex>/at/<address> — the value at any address
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.
7
15
  //
8
16
  // State lives in plain JS variables; reactivity is bridged from each Repo's
9
17
  // internal Recaller into the app-level Recaller via the `signal` pattern
@@ -15,6 +23,7 @@ import { Recaller } from '../../streamo/utils/Recaller.js'
15
23
  import { RepoRegistry } from '../../streamo/RepoRegistry.js'
16
24
  import { registrySync } from '../../streamo/registrySync.js'
17
25
  import { changedPaths } from '../../streamo/Streamo.js'
26
+ import { hexToBytes } from '../../streamo/utils.js'
18
27
 
19
28
  // ── Connect ───────────────────────────────────────────────────────────────
20
29
 
@@ -45,7 +54,13 @@ let scheduled = false
45
54
  function fire () {
46
55
  if (scheduled) return
47
56
  scheduled = true
48
- schedule(() => { scheduled = false; recaller.reportKeyMutation(signal, 'data') })
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
+ })
49
64
  }
50
65
 
51
66
  const watched = new Set()
@@ -63,18 +78,21 @@ registry.onOpen((k, r) => { watchRepo(k, r); fire() })
63
78
  // ── Hash routing ──────────────────────────────────────────────────────────
64
79
 
65
80
  function viewFromHash () {
66
- const m = (location.hash || '#/').match(/^#\/repo\/([0-9a-f]+)(?:\/at\/(\d+))?\/?$/i)
81
+ const m = (location.hash || '#/').match(/^#\/repo\/([0-9a-f]+)(?:\/at\/(HEAD|\d+))?\/?$/i)
67
82
  if (!m) return { kind: 'registry' }
68
- if (m[2] != null) return { kind: 'at', keyHex: m[1], address: +m[2] }
69
- return { kind: 'repo', keyHex: m[1] }
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 }
70
88
  }
71
89
 
72
90
  function hashFromView (v) {
73
- switch (v.kind) {
74
- case 'repo': return `#/repo/${v.keyHex}`
75
- case 'at': return `#/repo/${v.keyHex}/at/${v.address}`
76
- default: return '#/'
77
- }
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}`
78
96
  }
79
97
 
80
98
  let view = viewFromHash()
@@ -91,6 +109,11 @@ window.addEventListener('hashchange', () => {
91
109
  fire()
92
110
  })
93
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
+
94
117
  // ── Helpers ───────────────────────────────────────────────────────────────
95
118
 
96
119
  const truncKey = k => k.slice(0, 12) + '…'
@@ -111,42 +134,164 @@ function safeJSON (value) {
111
134
  }, 2)
112
135
  }
113
136
 
114
- // Walk every chunk newest-to-oldest. Each chunk's address is the index of
115
- // its last byte; the next chunk back ends at addr - chunk.length.
116
- function * repoEntries (repo) {
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) {
117
149
  const len = repo.byteLength
118
150
  if (len <= 0) return
119
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
+
120
160
  while (addr >= 0) {
121
161
  const code = repo.resolve(addr)
122
- if (!code || !code.length) return
162
+ if (!code || !code.length) break
123
163
  const type = repo.footerToCodec[code.at(-1)]?.type
164
+
124
165
  if (type === 'SIGNATURE') {
166
+ for (const e of flush()) yield e
125
167
  let sig
126
168
  try { sig = repo.decode(addr) } catch { sig = null }
127
- if (sig) {
128
- // Per Streamo.sign / .verify, signed range is [sig.address, sigAddr - chunkLen + 1),
129
- // i.e. last covered byte index = sigAddr - chunkLen. The sig chunk itself
130
- // spans [sigAddr - chunkLen + 1, sigAddr], so coverage runs right up to
131
- // (but does not include) the sig chunk's first byte.
132
- yield {
133
- kind: 'signature',
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({
134
183
  address: addr,
135
- signedFrom: sig.address,
136
- signedTo: addr - code.length,
137
- chunkStart: addr - code.length + 1,
138
- hex: truncHex(sig.compactRawBytes, 12)
139
- }
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 }
140
191
  }
141
- } else if (type === 'OBJECT') {
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') {
142
280
  let value
143
281
  try { value = repo.decode(addr) } catch { value = null }
144
282
  if (isCommitShape(value)) {
145
- yield { kind: 'commit', address: addr, message: value.message, date: value.date, dataAddress: value.dataAddress, parent: value.parent }
283
+ commits.push({
284
+ address: addr,
285
+ message: value.message,
286
+ date: value.date,
287
+ dataAddress: value.dataAddress,
288
+ parent: value.parent
289
+ })
146
290
  }
147
291
  }
148
292
  addr -= code.length
149
293
  }
294
+ return commits
150
295
  }
151
296
 
152
297
  // Decode the value at an address but treat object/array as REFS (children
@@ -183,66 +328,167 @@ function RegistryView () {
183
328
  `
184
329
  }
185
330
 
186
- function RepoView ({ keyHex }) {
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]
187
377
  return h`
188
- <a class="back" data-action="back-registry">← all repos</a>
189
- <div class="keyfull">${keyHex}</div>
190
- ${() => {
191
- dep()
192
- const repo = registry.get(keyHex)
193
- if (!repo) return h`<div class="empty">opening…</div>`
194
- const entries = [...repoEntries(repo)]
195
- if (!entries.length) {
196
- return h`
197
- <h2>chunks <span class="dim">(0)</span></h2>
198
- <div class="empty">no signed commits yet</div>
199
- `
200
- }
201
- const commitCount = entries.filter(e => e.kind === 'commit').length
202
- const sigCount = entries.length - commitCount
203
- return h`
204
- <h2>chunks <span class="dim">(${commitCount} commit${commitCount === 1 ? '' : 's'} · ${sigCount} sig${sigCount === 1 ? '' : 's'})</span></h2>
205
- ${entries.map(e => e.kind === 'commit'
206
- ? h`
207
- <div class="row commit" data-key=${`c${e.address}`} data-action="open-at"
208
- data-keyhex=${keyHex} data-addr=${e.address}>
209
- <span class="kind">commit</span>
210
- <span class="msg">${e.message || h`<span class="dim">(no message)</span>`}</span>
211
- <span class="when">${fmtDate(e.date)}</span>
212
- <span class="mono dim">@${e.address}</span>
213
- </div>`
214
- : h`
215
- <div class="row signature" data-key=${`s${e.address}`} data-action="open-at"
216
- data-keyhex=${keyHex} data-addr=${e.address}>
217
- <span class="kind">sig</span>
218
- <span class="mono dim">covers @${e.signedFrom}…@${e.signedTo}</span>
219
- <span class="mono dim">${e.hex}</span>
220
- <span class="mono dim">@${e.address}</span>
221
- </div>`
222
- )}
223
- `
224
- }}
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}
225
426
  `
226
427
  }
227
428
 
228
429
  function AtView ({ keyHex, address }) {
229
430
  return h`
230
- <a class="back" data-action="back-repo" data-keyhex=${keyHex}>← chunks</a>
231
- <div class="keyfull">${truncKey(keyHex)} @ ${address}</div>
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>
232
436
  ${() => {
233
437
  dep()
234
438
  const repo = registry.get(keyHex)
235
439
  if (!repo) return h`<div class="empty">opening…</div>`
236
- if (address >= repo.byteLength) return h`<div class="empty">loading…</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>`
237
456
 
238
457
  let info
239
- try { info = valueAndChildren(repo, address) }
458
+ try { info = valueAndChildren(repo, resolvedAddr) }
240
459
  catch (e) { return h`<pre class="value">decode error: ${e.message}</pre>` }
241
460
 
242
461
  const { codecType, refs, decoded } = info
243
462
  const isCommit = isCommitShape(decoded)
463
+ const isSig = codecType === 'SIGNATURE'
244
464
 
245
- // For commits, render the rich commit panel + changed paths.
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.
246
492
  if (isCommit) {
247
493
  const parentDataAddr = decoded.parent !== undefined
248
494
  ? safeGet(() => repo.decode(decoded.parent)?.dataAddress)
@@ -251,6 +497,7 @@ function AtView ({ keyHex, address }) {
251
497
  ? [...changedPaths(repo, parentDataAddr, decoded.dataAddress)]
252
498
  : null
253
499
  return h`
500
+ ${tabs}
254
501
  <div class="dim">codec: ${codecType} · this is a commit</div>
255
502
  <table class="kv">
256
503
  <tbody>
@@ -282,34 +529,41 @@ function AtView ({ keyHex, address }) {
282
529
  : null}
283
530
  <h3>rehydrated</h3>
284
531
  <pre class="value">${safeJSON(decoded)}</pre>
285
- ${rawChunkSection(repo, address)}
286
532
  `
287
533
  }
288
534
 
289
- // Signature: dedicated layout.
290
- if (codecType === 'SIGNATURE') {
291
- const chunk = repo.resolve(address)
292
- const chunkLen = chunk.length
293
- const signedTo = address - chunkLen // last byte covered (inclusive)
294
- const sigChunkStart = address - chunkLen + 1 // first byte of the sig chunk
535
+ // Duple: explain what this tree-node IS, then show its two children.
536
+ if (codecType === 'DUPLE') {
295
537
  return h`
296
- <div class="dim">codec: ${codecType}</div>
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>
297
549
  <table class="kv">
298
550
  <tbody>
299
- <tr>
300
- <td>covers</td>
301
- <td><a class="addr-link" data-action="open-at"
302
- data-keyhex=${keyHex} data-addr=${decoded.address}
303
- >@${decoded.address}</a> through @${signedTo} (${signedTo - decoded.address + 1} bytes)</td>
304
- </tr>
305
- <tr>
306
- <td>sig chunk</td>
307
- <td class="mono">@${sigChunkStart}…@${address} (${chunkLen} bytes)</td>
308
- </tr>
309
- <tr><td>bytes</td><td class="mono">${truncHex(decoded.compactRawBytes, 32)}</td></tr>
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>
310
553
  </tbody>
311
554
  </table>
312
- ${rawChunkSection(repo, address)}
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>
313
567
  `
314
568
  }
315
569
 
@@ -321,21 +575,34 @@ function AtView ({ keyHex, address }) {
321
575
  : Object.entries(refs)
322
576
  if (entries.length === 0) {
323
577
  return h`
578
+ ${tabs}
324
579
  <div class="dim">codec: ${codecType}</div>
325
580
  <div class="empty">${isArray ? '[]' : '{}'}</div>
326
- ${rawChunkSection(repo, address)}
327
581
  `
328
582
  }
329
583
  return h`
584
+ ${tabs}
330
585
  <div class="dim">codec: ${codecType}${isArray ? ` · length ${entries.length}` : ''}</div>
331
586
  <table class="kv clickable">
332
587
  <tbody>
333
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
+ }
334
603
  let preview = ''
335
- try {
336
- const v = repo.decode(childAddr)
337
- preview = previewValue(v)
338
- } catch { preview = '(error)' }
604
+ try { preview = previewValue(repo.decode(childAddr)) }
605
+ catch { preview = '(error)' }
339
606
  return h`
340
607
  <tr data-key=${k} data-action="open-at"
341
608
  data-keyhex=${keyHex} data-addr=${childAddr}>
@@ -349,15 +616,14 @@ function AtView ({ keyHex, address }) {
349
616
  </table>
350
617
  <h3>rehydrated</h3>
351
618
  <pre class="value">${safeJSON(decoded)}</pre>
352
- ${rawChunkSection(repo, address)}
353
619
  `
354
620
  }
355
621
 
356
622
  // Primitive: just show it.
357
623
  return h`
624
+ ${tabs}
358
625
  <div class="dim">codec: ${codecType}</div>
359
626
  <pre class="value">${safeJSON(decoded)}</pre>
360
- ${rawChunkSection(repo, address)}
361
627
  `
362
628
  }}
363
629
  `
@@ -377,12 +643,198 @@ function rawChunkSection (repo, address) {
377
643
  `
378
644
  }
379
645
 
380
- function previewValue (v) {
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) {
381
829
  if (v == null) return String(v)
382
830
  if (typeof v === 'string') return v.length > 60 ? JSON.stringify(v.slice(0, 60)) + '…' : JSON.stringify(v)
383
831
  if (typeof v === 'number' || typeof v === 'boolean') return String(v)
384
832
  if (v instanceof Date) return v.toISOString()
385
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
+ }
386
838
  if (Array.isArray(v)) return `[…] (${v.length})`
387
839
  if (typeof v === 'object') return `{…} (${Object.keys(v).length})`
388
840
  return String(v)
@@ -390,6 +842,96 @@ function previewValue (v) {
390
842
 
391
843
  function safeGet (f) { try { return f() } catch { return undefined } }
392
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
+
393
935
  // Hex dump of a chunk's raw bytes. Truncates at maxLen so a giant value
394
936
  // chunk doesn't blow up the page.
395
937
  function hexDump (bytes, maxLen = 256) {
@@ -410,25 +952,127 @@ function hexDump (bytes, maxLen = 256) {
410
952
 
411
953
  const appEl = document.getElementById('app')
412
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.
413
961
  mount(h`${() => {
414
962
  dep()
415
963
  switch (view.kind) {
416
- case 'registry': return RegistryView()
417
- case 'repo': return RepoView({ keyHex: view.keyHex })
418
- case 'at': return AtView({ keyHex: view.keyHex, address: view.address })
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>`
419
966
  default: return h`<div class="empty">?</div>`
420
967
  }
421
968
  }}`, appEl, recaller)
422
969
 
423
970
  // ── Click delegation ──────────────────────────────────────────────────────
424
971
 
972
+ let suppressClickUntil = 0
425
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
426
978
  const el = e.target.closest('[data-action]')
427
979
  if (!el) return
428
980
  switch (el.dataset.action) {
429
- case 'open-repo': return go({ kind: 'repo', keyHex: el.dataset.key })
981
+ case 'open-repo': return go({ kind: 'at', keyHex: el.dataset.key, address: 'HEAD' })
430
982
  case 'open-at': return go({ kind: 'at', keyHex: el.dataset.keyhex, address: +el.dataset.addr })
431
983
  case 'back-registry': return go({ kind: 'registry' })
432
- case 'back-repo': return go({ kind: 'repo', keyHex: el.dataset.keyhex })
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 {}
433
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'))
434
1078
  })