@dtudury/streamo 3.0.0 → 4.0.1

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,44 +134,150 @@ 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-first, yielding one entry per commit (with
138
+ // its covering signature attached) and one 'other' entry per non-commit
139
+ // non-sig chunk. A signature is part of *how* a commit is verified, not
140
+ // a thing of its own — so the user-level unit is the commit. Walking
141
+ // newest-first, we encounter each sig before the commits it covers
142
+ // (sig has higher address than the bytes it signed); we track the
143
+ // most-recently-seen sig and attach it to subsequent commits as their
144
+ // 'covering'. Commits encountered before any sig are uncovered (sign
145
+ // in flight or none yet) — those have covering: null.
146
+ function * commitsNewestFirst (repo) {
117
147
  const len = repo.byteLength
118
148
  if (len <= 0) return
119
149
  let addr = len - 1
150
+ let covering = null // most-recent sig encountered in this walk
120
151
  while (addr >= 0) {
121
152
  const code = repo.resolve(addr)
122
- if (!code || !code.length) return
153
+ if (!code || !code.length) break
123
154
  const type = repo.footerToCodec[code.at(-1)]?.type
124
155
  if (type === 'SIGNATURE') {
125
156
  let sig
126
157
  try { sig = repo.decode(addr) } catch { sig = null }
127
158
  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',
134
- address: addr,
159
+ covering = {
160
+ sigAddress: addr,
135
161
  signedFrom: sig.address,
136
162
  signedTo: addr - code.length,
137
- chunkStart: addr - code.length + 1,
138
- hex: truncHex(sig.compactRawBytes, 12)
163
+ sigHex: truncHex(sig.compactRawBytes, 12)
139
164
  }
140
165
  }
166
+ yield { kind: 'sig', address: addr, codecType: type }
141
167
  } else if (type === 'OBJECT') {
142
168
  let value
143
169
  try { value = repo.decode(addr) } catch { value = null }
144
170
  if (isCommitShape(value)) {
145
- yield { kind: 'commit', address: addr, message: value.message, date: value.date, dataAddress: value.dataAddress, parent: value.parent }
171
+ yield {
172
+ kind: 'commit',
173
+ address: addr,
174
+ message: value.message,
175
+ date: value.date,
176
+ dataAddress: value.dataAddress,
177
+ parent: value.parent,
178
+ covering
179
+ }
180
+ } else {
181
+ yield { kind: 'other', address: addr, codecType: type }
146
182
  }
183
+ } else {
184
+ yield { kind: 'other', address: addr, codecType: type }
147
185
  }
148
186
  addr -= code.length
149
187
  }
150
188
  }
151
189
 
190
+ // Find the covering signature for a commit — the first signature chunk
191
+ // newer than the commit whose [signedFrom, signedTo] range includes its
192
+ // address. Returns { sigAddress, signedFrom, signedTo, decoded } or null
193
+ // if the commit is uncovered (sign in flight or pending).
194
+ function findCoveringSig (repo, commitAddr) {
195
+ let scan = repo.byteLength - 1
196
+ while (scan > commitAddr) {
197
+ const code = repo.resolve(scan)
198
+ if (!code || !code.length) break
199
+ if (repo.footerToCodec[code.at(-1)]?.type === 'SIGNATURE') {
200
+ let sig
201
+ try { sig = repo.decode(scan) } catch { sig = null }
202
+ if (sig && sig.address <= commitAddr && (scan - code.length) >= commitAddr) {
203
+ return { sigAddress: scan, signedFrom: sig.address, signedTo: scan - code.length, decoded: sig }
204
+ }
205
+ }
206
+ scan -= code.length
207
+ }
208
+ return null
209
+ }
210
+
211
+ // Sig-detail view — when you're at a sig chunk directly (e.g., from
212
+ // drilling through storage). Sigs are auxiliary in the new model — the
213
+ // user-level unit is the commit — so this page shows the sig's content
214
+ // without trying to be the "polished signed commit" page. The kindBanner
215
+ // is rendered by the caller (AtView) so its variant matches the rest of
216
+ // the value-tab branches.
217
+ function sigDetailBody (repo, keyHex, sigAddress, decoded) {
218
+ const chunk = repo.resolve(sigAddress)
219
+ const chunkLen = chunk.length
220
+ const signedTo = sigAddress - chunkLen
221
+ const sigChunkStart = sigAddress - chunkLen + 1
222
+ const covered = commitsCoveredBySignature(repo, decoded.address, signedTo)
223
+ return h`
224
+ <table class="kv">
225
+ <tbody>
226
+ <tr>
227
+ <td>covers</td>
228
+ <td>@${decoded.address} through @${signedTo} (${signedTo - decoded.address + 1} bytes)</td>
229
+ </tr>
230
+ <tr>
231
+ <td>sig chunk</td>
232
+ <td class="mono">@${sigChunkStart}…@${sigAddress} (${chunkLen} bytes)</td>
233
+ </tr>
234
+ <tr><td>bytes</td><td class="mono">${truncHex(decoded.compactRawBytes, 32)}</td></tr>
235
+ </tbody>
236
+ </table>
237
+ ${covered.length ? h`
238
+ <h3>commits in this signature ${covered.length > 1 ? h`<span class="dim">(${covered.length}, batched in one sign)</span>` : null}</h3>
239
+ ${covered.map(c => h`
240
+ <div class="commit-card" data-key=${`cc${c.address}`} data-action="open-at"
241
+ data-keyhex=${keyHex} data-addr=${c.address}>
242
+ <div class="commit-msg">${c.message || h`<span class="dim">(no message)</span>`}</div>
243
+ <div class="commit-meta dim">
244
+ <span>${fmtDate(c.date)}</span>
245
+ <span> · @${c.address}</span>
246
+ </div>
247
+ </div>
248
+ `)}
249
+ ` : null}
250
+ `
251
+ }
252
+
253
+ // Find the commits (newest-first) covered by a particular signature. Used
254
+ // by the at-view's SIGNATURE branch to assemble the "this is what you were
255
+ // looking for" polished view from a sig address alone.
256
+ function commitsCoveredBySignature (repo, signedFrom, signedTo) {
257
+ const commits = []
258
+ let addr = signedTo
259
+ while (addr >= signedFrom) {
260
+ const code = repo.resolve(addr)
261
+ if (!code || !code.length) break
262
+ const type = repo.footerToCodec[code.at(-1)]?.type
263
+ if (type === 'OBJECT') {
264
+ let value
265
+ try { value = repo.decode(addr) } catch { value = null }
266
+ if (isCommitShape(value)) {
267
+ commits.push({
268
+ address: addr,
269
+ message: value.message,
270
+ date: value.date,
271
+ dataAddress: value.dataAddress,
272
+ parent: value.parent
273
+ })
274
+ }
275
+ }
276
+ addr -= code.length
277
+ }
278
+ return commits
279
+ }
280
+
152
281
  // Decode the value at an address but treat object/array as REFS (children
153
282
  // are addresses, not decoded recursively). For primitives, returns the
154
283
  // decoded value directly.
@@ -183,95 +312,254 @@ function RegistryView () {
183
312
  `
184
313
  }
185
314
 
186
- function RepoView ({ keyHex }) {
187
- return h`
188
- <a class="back" data-action="back-registry">← all repos</a>
189
- <div class="keyfull">${keyHex}</div>
190
- ${() => {
315
+ // Resolve the symbolic HEAD address to the most-recent COMMIT chunk's
316
+ // address — not the most-recent signature. The user-level unit is the
317
+ // commit; sigs are how it's verified, but HEAD-as-a-commit is what
318
+ // people mean by "the latest." Returns undefined if there are no commits.
319
+ function resolveHead (repo) {
320
+ let walk = repo.byteLength - 1
321
+ while (walk >= 0) {
322
+ const code = repo.resolve(walk)
323
+ if (!code || !code.length) break
324
+ if (repo.footerToCodec[code.at(-1)]?.type === 'OBJECT') {
325
+ let value
326
+ try { value = repo.decode(walk) } catch { value = null }
327
+ if (isCommitShape(value)) return walk
328
+ }
329
+ walk -= code.length
330
+ }
331
+ return undefined
332
+ }
333
+
334
+ // Commit selector dropdown — always rendered at the top of an at-view
335
+ // when the repo has any commits. The dropdown enumerates COMMITS (not
336
+ // sigs), since the commit is the user-level unit. Each entry's verify
337
+ // badge comes from its covering sig; uncovered commits show a "pending"
338
+ // badge. When the current address is a commit, that row is the summary;
339
+ // otherwise the summary is a "detached" card (you're at a sig chunk, a
340
+ // Duple, raw bytes, etc. — drill state, not a named ref).
341
+ function commitSelectorSection (repo, keyHex, currentAddr) {
342
+ const entries = [...commitsNewestFirst(repo)].filter(e => e.kind === 'commit')
343
+ if (!entries.length) return null
344
+ const tagFor = i => i === 0 ? 'HEAD' : `HEAD-${i}`
345
+ const commitRow = (c, tag, { asSummary = false, isSelected = false } = {}) => {
346
+ const cls = ['row', 'signed-commit',
347
+ asSummary ? 'head-card' : null,
348
+ isSelected ? 'selected' : null,
349
+ c.covering ? null : 'unsigned']
350
+ const action = asSummary ? null : 'select-commit'
351
+ const badge = () => {
191
352
  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
- }}
353
+ if (!c.covering) return h`<span class="verify-badge pending" title="not yet signed">…</span>`
354
+ return verifyBadge(verifyStatus(repo, keyHex, c.covering.decoded || repo.decode(c.covering.sigAddress), c.covering.sigAddress))
355
+ }
356
+ return h`
357
+ <div class=${cls}
358
+ data-key=${`c${c.address}`}
359
+ data-action=${action}
360
+ data-keyhex=${keyHex} data-addr=${c.address}>
361
+ <span class="kind">${tag} ${badge}</span>
362
+ <span class="msg">${c.message || h`<span class="dim">(no message)</span>`}</span>
363
+ <span class="when">${fmtDate(c.date)}</span>
364
+ <span class="mono dim">@${c.address}</span>
365
+ </div>`
366
+ }
367
+ const selectedIdx = entries.findIndex(e => e.address === currentAddr)
368
+ const isDetached = selectedIdx < 0
369
+ const detachedSummary = (() => {
370
+ let codec = ''
371
+ try { codec = repo.footerToCodec[repo.resolve(currentAddr).at(-1)]?.type || '' } catch {}
372
+ return h`
373
+ <div class="row signed-commit detached-card" data-key="detached">
374
+ <span class="kind">detached</span>
375
+ <span class="msg dim">exploring raw memory${codec ? ` · ${codec}` : ''}</span>
376
+ <span class="when"></span>
377
+ <span class="mono dim">@${currentAddr}</span>
378
+ </div>`
379
+ })()
380
+ const summary = isDetached
381
+ ? detachedSummary
382
+ : commitRow(entries[selectedIdx], tagFor(selectedIdx), { asSummary: true })
383
+ return h`
384
+ <details class="commit-selector" data-key=${`selector-${keyHex}`}>
385
+ <summary>${summary}</summary>
386
+ <div class="dropdown-body">
387
+ ${entries.map((e, i) => commitRow(e, tagFor(i), { isSelected: !isDetached && i === selectedIdx }))}
388
+ </div>
389
+ </details>
390
+ `
391
+ }
392
+
393
+ // Repo-wide "other storage chunks" list — Duples, raw OBJECTs, ARRAYs,
394
+ // STRINGs, etc. The chunks underneath the commit graph. Tucked into a
395
+ // closed <details> so it doesn't compete with primary content. Unsigned
396
+ // commits already appear in the selector dropdown (with a pending badge),
397
+ // so they don't need a second listing here.
398
+ function repoExtras (repo, keyHex) {
399
+ const others = [...commitsNewestFirst(repo)].filter(e => e.kind === 'other')
400
+ if (!others.length) return null
401
+ return h`
402
+ <details class="other-storage">
403
+ <summary>storage chunks <span class="dim">(${others.length}) — the chunks underneath</span></summary>
404
+ <table class="kv clickable">
405
+ <tbody>
406
+ ${others.map(e => h`
407
+ <tr data-key=${`o${e.address}`} data-action="open-at"
408
+ data-keyhex=${keyHex} data-addr=${e.address}>
409
+ <td class="mono dim">${e.codecType}</td>
410
+ <td>${(() => { try { return previewValue(repo.decode(e.address)) } catch { return '' } })()}</td>
411
+ <td class="mono dim">@${e.address}</td>
412
+ </tr>
413
+ `)}
414
+ </tbody>
415
+ </table>
416
+ </details>
225
417
  `
226
418
  }
227
419
 
228
420
  function AtView ({ keyHex, address }) {
229
421
  return h`
230
- <a class="back" data-action="back-repo" data-keyhex=${keyHex}>← chunks</a>
231
- <div class="keyfull">${truncKey(keyHex)} @ ${address}</div>
422
+ <a class="back" data-action="back-registry">← all repos</a>
423
+ <div class="keyfull">
424
+ <a class="repo-link" data-action="back-repo" data-keyhex=${keyHex}>${truncKey(keyHex)}</a>
425
+ <span class="dim"> @ ${address}</span>
426
+ </div>
232
427
  ${() => {
233
428
  dep()
234
429
  const repo = registry.get(keyHex)
235
430
  if (!repo) return h`<div class="empty">opening…</div>`
236
- if (address >= repo.byteLength) return h`<div class="empty">loading…</div>`
431
+
432
+ // Resolve HEAD (symbolic) to the most-recent sig address. If the
433
+ // repo has no commits yet, render a useful "no HEAD" page that
434
+ // still surfaces any storage chunks.
435
+ let resolvedAddr = address
436
+ if (address === 'HEAD') {
437
+ resolvedAddr = resolveHead(repo)
438
+ if (resolvedAddr === undefined) {
439
+ return h`
440
+ <h2>at HEAD <span class="dim">(no commits yet)</span></h2>
441
+ <div class="empty">this repo doesn't have any commits yet — HEAD will resolve to the most-recent commit once one lands.</div>
442
+ ${repoExtras(repo, keyHex)}
443
+ `
444
+ }
445
+ }
446
+ if (resolvedAddr >= repo.byteLength) return h`<div class="empty">loading…</div>`
237
447
 
238
448
  let info
239
- try { info = valueAndChildren(repo, address) }
449
+ try { info = valueAndChildren(repo, resolvedAddr) }
240
450
  catch (e) { return h`<pre class="value">decode error: ${e.message}</pre>` }
241
451
 
242
452
  const { codecType, refs, decoded } = info
243
453
  const isCommit = isCommitShape(decoded)
454
+ const isSig = codecType === 'SIGNATURE'
455
+
456
+ // Tabs are part of the page content (not the static header) so the
457
+ // commit selector renders ABOVE the tabs. The selector is always
458
+ // present (when the repo has any commits) so the UI doesn't shift
459
+ // as you click between commit pages and storage drilling — when
460
+ // the current address isn't a commit, the summary shows "detached".
461
+ const tabs = h`
462
+ <nav class="tabs">
463
+ <a class=${() => { dep(); return ['tab', atTab === 'value' ? 'active' : null] }}
464
+ data-action="set-tab" data-tab="value">value</a>
465
+ <a class=${() => { dep(); return ['tab', atTab === 'storage' ? 'active' : null] }}
466
+ data-action="set-tab" data-tab="storage">storage</a>
467
+ </nav>
468
+ `
469
+ const selector = commitSelectorSection(repo, keyHex, resolvedAddr)
470
+
471
+ // Storage tab: spatial view of where this chunk lives in the byte
472
+ // stream + outgoing references + this chunk's bytes + incoming
473
+ // referrers. The chunk graph from this chunk's perspective.
474
+ if (atTab === 'storage') {
475
+ return h`
476
+ ${selector}
477
+ ${tabs}
478
+ ${byteStreamSection(repo, keyHex, resolvedAddr)}
479
+ ${outgoingReferencesSection(repo, keyHex, resolvedAddr)}
480
+ ${rawChunkSection(repo, resolvedAddr)}
481
+ ${referrersSection(repo, keyHex, resolvedAddr)}
482
+ `
483
+ }
484
+
485
+ // Every value-tab branch below prepends ${selector}${tabs} so the
486
+ // UI is stable across navigation: the selector is always at the
487
+ // top of the page when the repo has any sigs.
244
488
 
245
- // For commits, render the rich commit panel + changed paths.
489
+ // Value tab branches by codec.
490
+ // Helper: render the kv-table of decoded fields for any Object/Array
491
+ // (including commits, which are just OBJECTs with a known shape).
492
+ // Inline children render their value directly; addressable children
493
+ // get a clickable @addr link in the third column.
494
+ const refsTable = () => {
495
+ if (!refs || typeof refs !== 'object') return null
496
+ const isArray = Array.isArray(refs)
497
+ const fieldEntries = isArray
498
+ ? refs.map((addr, i) => [String(i), addr])
499
+ : Object.entries(refs)
500
+ if (fieldEntries.length === 0) return h`<div class="empty">${isArray ? '[]' : '{}'}</div>`
501
+ return h`
502
+ <table class="kv clickable">
503
+ <tbody>
504
+ ${fieldEntries.map(([k, childAddr]) => {
505
+ if (childAddr === undefined) {
506
+ const inlineValue = isArray ? decoded[+k] : decoded[k]
507
+ return h`
508
+ <tr>
509
+ <td class="mono">${k}</td>
510
+ <td>${previewValue(inlineValue)}</td>
511
+ <td class="dim">(inline)</td>
512
+ </tr>
513
+ `
514
+ }
515
+ let preview = ''
516
+ try { preview = previewValue(repo.decode(childAddr)) }
517
+ catch { preview = '(error)' }
518
+ return h`
519
+ <tr data-key=${k} data-action="open-at"
520
+ data-keyhex=${keyHex} data-addr=${childAddr}>
521
+ <td class="mono">${k}</td>
522
+ <td>${preview}</td>
523
+ <td class="mono dim">@${childAddr}</td>
524
+ </tr>
525
+ `
526
+ })}
527
+ </tbody>
528
+ </table>
529
+ `
530
+ }
531
+
532
+ // Commit: same direct kv-table format as Object (it *is* an OBJECT —
533
+ // user requested the "dumber" version that names every field rather
534
+ // than packing them into a polished headline). Banner shows the
535
+ // verify state from the covering sig; the verification table at the
536
+ // bottom links to that sig and shows its bytes.
246
537
  if (isCommit) {
538
+ const covering = findCoveringSig(repo, resolvedAddr)
247
539
  const parentDataAddr = decoded.parent !== undefined
248
540
  ? safeGet(() => repo.decode(decoded.parent)?.dataAddress)
249
541
  : undefined
250
542
  const changes = parentDataAddr !== undefined
251
543
  ? [...changedPaths(repo, parentDataAddr, decoded.dataAddress)]
252
544
  : null
545
+ const banner = kindBanner(
546
+ covering ? 'signed commit' : 'commit (unsigned)',
547
+ covering
548
+ ? () => {
549
+ dep()
550
+ const status = verifyStatus(repo, keyHex, covering.decoded, covering.sigAddress)
551
+ return h`${verifyBadge(status)} <span class="dim">${verifyLabel(status)}</span>`
552
+ }
553
+ : h`<span class="verify-badge pending">…</span><span class="dim">not yet signed — sign in flight or pending</span>`,
554
+ covering ? 'verified' : 'unsigned'
555
+ )
253
556
  return h`
254
- <div class="dim">codec: ${codecType} · this is a commit</div>
255
- <table class="kv">
256
- <tbody>
257
- <tr><td>message</td><td>${decoded.message || h`<span class="dim">(empty)</span>`}</td></tr>
258
- <tr><td>date</td><td>${fmtDate(decoded.date)}</td></tr>
259
- <tr>
260
- <td>dataAddress</td>
261
- <td><a class="addr-link" data-action="open-at"
262
- data-keyhex=${keyHex} data-addr=${decoded.dataAddress}
263
- >@${decoded.dataAddress}</a></td>
264
- </tr>
265
- <tr>
266
- <td>parent</td>
267
- <td>${decoded.parent === undefined
268
- ? h`<span class="dim">(none — first commit)</span>`
269
- : h`<a class="addr-link" data-action="open-at"
270
- data-keyhex=${keyHex} data-addr=${decoded.parent}
271
- >@${decoded.parent}</a>`}</td>
272
- </tr>
273
- </tbody>
274
- </table>
557
+ ${selector}
558
+ ${tabs}
559
+ ${banner}
560
+ ${refsTable()}
561
+ <h3>rehydrated</h3>
562
+ <pre class="value">${safeJSON(decoded)}</pre>
275
563
  ${changes
276
564
  ? h`
277
565
  <h3>changed paths <span class="dim">(${changes.length})</span></h3>
@@ -280,84 +568,100 @@ function AtView ({ keyHex, address }) {
280
568
  : h`<div class="dim">(no path-level changes — same dataAddress)</div>`}
281
569
  `
282
570
  : null}
283
- <h3>rehydrated</h3>
284
- <pre class="value">${safeJSON(decoded)}</pre>
285
- ${rawChunkSection(repo, address)}
571
+ ${covering ? h`
572
+ <h3>verification</h3>
573
+ <table class="kv">
574
+ <tbody>
575
+ <tr>
576
+ <td>signature</td>
577
+ <td><a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${covering.sigAddress}>@${covering.sigAddress}</a></td>
578
+ </tr>
579
+ <tr>
580
+ <td>covers</td>
581
+ <td>@${covering.signedFrom} through @${covering.signedTo} (${covering.signedTo - covering.signedFrom + 1} bytes)</td>
582
+ </tr>
583
+ <tr><td>sig bytes</td><td class="mono">${truncHex(covering.decoded.compactRawBytes, 32)}</td></tr>
584
+ </tbody>
585
+ </table>
586
+ ` : null}
587
+ ${repoExtras(repo, keyHex)}
286
588
  `
287
589
  }
288
590
 
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
591
+ // Duple: explain what this tree-node IS, then show its two children.
592
+ if (codecType === 'DUPLE') {
295
593
  return h`
296
- <div class="dim">codec: ${codecType}</div>
594
+ ${selector}
595
+ ${tabs}
596
+ ${kindBanner('duple', h`<span class="dim">2-tuple, tree scaffolding</span>`)}
597
+ <p class="explainer">
598
+ A <strong>Duple</strong> is a 2-tuple — the building block streamo uses
599
+ to balance binary trees of OBJECT entries and ARRAY elements. Each Duple
600
+ holds two slots; the slots are either values (a leaf) or other Duples
601
+ (an interior tree node). They're how content-addressing scales to
602
+ larger objects/arrays without rewriting the whole structure on every
603
+ small change — siblings keep their addresses, and dedup happens at
604
+ every level of the tree.
605
+ </p>
297
606
  <table class="kv">
298
607
  <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>
608
+ <tr><td class="mono">v[0]</td><td>${previewValue(decoded.v[0])}</td></tr>
609
+ <tr><td class="mono">v[1]</td><td>${previewValue(decoded.v[1])}</td></tr>
310
610
  </tbody>
311
611
  </table>
312
- ${rawChunkSection(repo, address)}
612
+ `
613
+ }
614
+
615
+ // Signature: the sig-detail page (auxiliary in the new model — sigs
616
+ // are how commits are verified, not the user-level unit). Lists
617
+ // the commits this sig covers; pick one to land on its commit page.
618
+ if (isSig) {
619
+ const banner = kindBanner(
620
+ 'signature chunk',
621
+ () => {
622
+ dep()
623
+ const status = verifyStatus(repo, keyHex, decoded, resolvedAddr)
624
+ return h`${verifyBadge(status)} <span class="dim">${verifyLabel(status)}</span>`
625
+ },
626
+ 'verified'
627
+ )
628
+ return h`
629
+ ${selector}
630
+ ${tabs}
631
+ ${banner}
632
+ ${sigDetailBody(repo, keyHex, resolvedAddr, decoded)}
633
+ <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
634
  `
314
635
  }
315
636
 
316
637
  // Object/array: clickable children with their addresses.
317
638
  if (refs && typeof refs === 'object') {
318
639
  const isArray = Array.isArray(refs)
319
- const entries = isArray
320
- ? refs.map((addr, i) => [String(i), addr])
321
- : Object.entries(refs)
322
- if (entries.length === 0) {
323
- return h`
324
- <div class="dim">codec: ${codecType}</div>
325
- <div class="empty">${isArray ? '[]' : '{}'}</div>
326
- ${rawChunkSection(repo, address)}
327
- `
328
- }
640
+ const fieldCount = isArray ? refs.length : Object.keys(refs).length
641
+ const dim = fieldCount === 0
642
+ ? null
643
+ : h`<span class="dim">${isArray ? `length ${fieldCount}` : `${fieldCount} field${fieldCount === 1 ? '' : 's'}`}</span>`
644
+ const label = fieldCount === 0
645
+ ? (isArray ? 'empty array' : 'empty object')
646
+ : (isArray ? 'array' : 'object')
329
647
  return h`
330
- <div class="dim">codec: ${codecType}${isArray ? ` · length ${entries.length}` : ''}</div>
331
- <table class="kv clickable">
332
- <tbody>
333
- ${entries.map(([k, childAddr]) => {
334
- let preview = ''
335
- try {
336
- const v = repo.decode(childAddr)
337
- preview = previewValue(v)
338
- } catch { preview = '(error)' }
339
- return h`
340
- <tr data-key=${k} data-action="open-at"
341
- data-keyhex=${keyHex} data-addr=${childAddr}>
342
- <td class="mono">${k}</td>
343
- <td>${preview}</td>
344
- <td class="mono dim">@${childAddr}</td>
345
- </tr>
346
- `
347
- })}
348
- </tbody>
349
- </table>
350
- <h3>rehydrated</h3>
351
- <pre class="value">${safeJSON(decoded)}</pre>
352
- ${rawChunkSection(repo, address)}
648
+ ${selector}
649
+ ${tabs}
650
+ ${kindBanner(label, dim)}
651
+ ${refsTable()}
652
+ ${fieldCount > 0 ? h`
653
+ <h3>rehydrated</h3>
654
+ <pre class="value">${safeJSON(decoded)}</pre>
655
+ ` : null}
353
656
  `
354
657
  }
355
658
 
356
659
  // Primitive: just show it.
357
660
  return h`
358
- <div class="dim">codec: ${codecType}</div>
661
+ ${selector}
662
+ ${tabs}
663
+ ${kindBanner(codecType.toLowerCase())}
359
664
  <pre class="value">${safeJSON(decoded)}</pre>
360
- ${rawChunkSection(repo, address)}
361
665
  `
362
666
  }}
363
667
  `
@@ -377,12 +681,198 @@ function rawChunkSection (repo, address) {
377
681
  `
378
682
  }
379
683
 
380
- function previewValue (v) {
684
+ // Map a codec type to a visual category. Many distinct codecs map to a
685
+ // shared category so the byte-stream stripe stays readable: commits (the
686
+ // narrative anchors), signatures (attestations), composite values, the
687
+ // Duple tree-scaffolding, strings, bytes, numbers, etc.
688
+ function codecCategory (type) {
689
+ switch (type) {
690
+ case 'SIGNATURE': return 'sig'
691
+ case 'OBJECT': case 'EMPTY_OBJECT': case 'ARRAY': case 'EMPTY_ARRAY': return 'composite'
692
+ case 'DUPLE': return 'duple'
693
+ case 'STRING': case 'EMPTY_STRING': return 'string'
694
+ case 'WORD': case 'UINT8ARRAY': case 'EMPTY_UINT8ARRAY': return 'bytes'
695
+ case 'DATE': case 'FLOAT64': case 'UINT7': return 'num'
696
+ case 'VARIABLE': return 'var'
697
+ default: return 'other'
698
+ }
699
+ }
700
+
701
+ // Byte stream as a color-coded SVG strip — every chunk is a rect, color
702
+ // coded by codec category. Modestly zoomed so even 1-byte chunks have a
703
+ // clickable width; horizontally scrollable, click-drag-to-pan inside the
704
+ // strip (cursor: grab/grabbing). First render auto-scrolls to HEAD (the
705
+ // newest content, at the right) and stays pinned there if you haven't
706
+ // dragged off it — so a live stream "follows" the newest activity. The
707
+ // signed-commits dropdown above is for jumping to a known commit; this
708
+ // strip is for poking around between them.
709
+ function byteStreamSection (repo, keyHex, currentAddress) {
710
+ const chunks = []
711
+ let addr = repo.byteLength - 1
712
+ while (addr >= 0) {
713
+ const code = repo.resolve(addr)
714
+ if (!code || !code.length) break
715
+ const codec = repo.footerToCodec[code.at(-1)]
716
+ chunks.unshift({
717
+ address: addr,
718
+ start: addr - code.length + 1,
719
+ length: code.length,
720
+ codecType: codec?.type || '?'
721
+ })
722
+ addr -= code.length
723
+ }
724
+ if (!chunks.length) return null
725
+
726
+ // Mark commit addresses by walking history once — cheap, lets commits
727
+ // appear as their own visual category instead of getting lumped in with
728
+ // generic OBJECTs.
729
+ const commitAddrs = new Set()
730
+ let walkAddr = repo.valueAddress
731
+ while (walkAddr !== undefined && walkAddr >= 0) {
732
+ let commit
733
+ try { commit = repo.decode(walkAddr) } catch { break }
734
+ if (!commit || typeof commit.message !== 'string' || !(commit.date instanceof Date)) break
735
+ commitAddrs.add(walkAddr)
736
+ walkAddr = commit.parent
737
+ }
738
+
739
+ const total = repo.byteLength
740
+ // Each chunk gets max(MIN_PX, proportional zoomed width). At ZOOM=2 the
741
+ // strip is roughly 2x viewport-wide for typical repos — enough to
742
+ // scroll/drag through without losing spatial sense, and MIN_PX keeps
743
+ // even 1-byte chunks clickable.
744
+ const ZOOM = 2
745
+ const MIN_PX = 8
746
+ const H = 36
747
+ const zoomedW = 1200 * ZOOM
748
+ let cursorX = 0
749
+ const layout = chunks.map(c => {
750
+ const propW = (c.length / total) * zoomedW
751
+ const w = Math.max(MIN_PX, propW)
752
+ const item = { ...c, x: cursorX, w }
753
+ cursorX += w
754
+ return item
755
+ })
756
+ const stripW = cursorX
757
+ return h`
758
+ <h3>byte stream <span class="dim">(${total} bytes · ${chunks.length} chunks)</span></h3>
759
+ <div class="byte-map-legend">
760
+ <span class="cat-commit">commit</span>
761
+ <span class="cat-sig">sig</span>
762
+ <span class="cat-composite">object/array</span>
763
+ <span class="cat-duple">duple</span>
764
+ <span class="cat-string">string</span>
765
+ <span class="cat-bytes">bytes</span>
766
+ <span class="cat-num">num</span>
767
+ <span class="cat-var">var</span>
768
+ </div>
769
+ <div class="byte-strip-container" data-key=${`strip-${keyHex}`}>
770
+ <svg class="byte-map byte-strip" width=${stripW} height=${H} viewBox=${`0 0 ${stripW} ${H}`}>
771
+ ${layout.map(c => {
772
+ const cat = commitAddrs.has(c.address) ? 'commit' : codecCategory(c.codecType)
773
+ const cls = ['chunk', `cat-${cat}`, c.address === currentAddress ? 'current' : null]
774
+ return h`<rect
775
+ class=${cls}
776
+ x=${c.x} y="0" width=${c.w} height=${H}
777
+ data-action="open-at"
778
+ data-keyhex=${keyHex}
779
+ data-addr=${c.address}
780
+ ><title>${c.codecType} @${c.address} (${c.length} bytes)</title></rect>`
781
+ })}
782
+ </svg>
783
+ </div>
784
+ `
785
+ }
786
+
787
+ // Outgoing references — what THIS chunk points to in the chunk graph (as
788
+ // opposed to "referenced by", which is what points to this chunk). Walks
789
+ // the codec's parts via repo.directReferences. Codec-by-codec — exposes
790
+ // the storage chain so e.g. STRING → UINT8ARRAY → DUPLE → DUPLE → … → WORD
791
+ // is browsable one click at a time.
792
+ function outgoingReferencesSection (repo, keyHex, address) {
793
+ const refs = repo.directReferences(address)
794
+ if (!refs.length) return null
795
+ return h`
796
+ <h3>references <span class="dim">(${refs.length})</span></h3>
797
+ <table class="kv clickable">
798
+ <tbody>
799
+ ${refs.map((childAddr, i) => {
800
+ let codecType = '?'
801
+ let preview = ''
802
+ try {
803
+ const childCode = repo.resolve(childAddr)
804
+ codecType = repo.footerToCodec[childCode.at(-1)]?.type || '?'
805
+ preview = previewValue(repo.decode(childAddr))
806
+ } catch { preview = '(error)' }
807
+ return h`
808
+ <tr data-key=${`out${i}@${childAddr}`} data-action="open-at"
809
+ data-keyhex=${keyHex} data-addr=${childAddr}>
810
+ <td class="mono dim">${codecType}</td>
811
+ <td>${preview}</td>
812
+ <td class="mono dim">@${childAddr}</td>
813
+ </tr>
814
+ `
815
+ })}
816
+ </tbody>
817
+ </table>
818
+ `
819
+ }
820
+
821
+ // "Referenced by" — walks up the Duple tree-scaffolding to find the chunks
822
+ // that USE this address in a user-meaningful sense (OBJECT, ARRAY, VARIABLE,
823
+ // SIGNATURE, etc.). Internal Duples are skipped — they're how the codec
824
+ // builds balanced trees, not where the user thinks about the data living.
825
+ //
826
+ // Each row shows: codec, a one-line preview of the value, the address, and
827
+ // — if more than one Duple path leads to the same ancestor — a path count.
828
+ function referrersSection (repo, keyHex, address) {
829
+ const index = buildReferrerIndex(repo)
830
+ const refs = findUserReferrers(repo, address, index)
831
+ if (!refs.length) {
832
+ return h`
833
+ <h3>referenced by <span class="dim">(0)</span></h3>
834
+ <div class="dim">no chunks in this repo reference this value</div>
835
+ `
836
+ }
837
+ return h`
838
+ <h3>referenced by <span class="dim">(${refs.length} ${refs.length === 1 ? 'place' : 'places'})</span></h3>
839
+ <table class="kv clickable">
840
+ <tbody>
841
+ ${refs.map(r => {
842
+ let preview = ''
843
+ try { preview = previewValue(repo.decode(r.address)) }
844
+ catch { preview = '(error)' }
845
+ return h`
846
+ <tr data-key=${`r${r.address}`} data-action="open-at"
847
+ data-keyhex=${keyHex} data-addr=${r.address}>
848
+ <td class="mono dim">${r.codecType || '?'}${r.count > 1 ? ` ×${r.count}` : ''}</td>
849
+ <td>${preview}</td>
850
+ <td class="mono dim">@${r.address}</td>
851
+ </tr>
852
+ `
853
+ })}
854
+ </tbody>
855
+ </table>
856
+ `
857
+ }
858
+
859
+ // Detect a Duple instance — codecs.js doesn't export the class so we have to
860
+ // duck-type. A Duple is an object whose only own property is `v`, a length-2
861
+ // array. (Used so we can render Duples as `[a, b]` rather than `{…} (1)`.)
862
+ function isDuple (v) {
863
+ return v && typeof v === 'object' && Array.isArray(v.v) && v.v.length === 2 && Object.keys(v).length === 1
864
+ }
865
+
866
+ function previewValue (v, depth = 0) {
381
867
  if (v == null) return String(v)
382
868
  if (typeof v === 'string') return v.length > 60 ? JSON.stringify(v.slice(0, 60)) + '…' : JSON.stringify(v)
383
869
  if (typeof v === 'number' || typeof v === 'boolean') return String(v)
384
870
  if (v instanceof Date) return v.toISOString()
385
871
  if (v instanceof Uint8Array) return `Uint8Array(${v.length})`
872
+ if (isDuple(v)) {
873
+ if (depth > 2) return 'Duple(…)'
874
+ return `Duple(${previewValue(v.v[0], depth + 1)}, ${previewValue(v.v[1], depth + 1)})`
875
+ }
386
876
  if (Array.isArray(v)) return `[…] (${v.length})`
387
877
  if (typeof v === 'object') return `{…} (${Object.keys(v).length})`
388
878
  return String(v)
@@ -390,6 +880,118 @@ function previewValue (v) {
390
880
 
391
881
  function safeGet (f) { try { return f() } catch { return undefined } }
392
882
 
883
+ // Build a child→parents index for the entire repo in one pass, so we can
884
+ // answer "who references address X?" in O(1) per query and walk up parent
885
+ // chains without re-scanning. Each entry maps a chunk's address to all the
886
+ // chunks that have it as a DIRECT child (via asRefs).
887
+ function buildReferrerIndex (repo) {
888
+ const index = new Map() // childAddr → [{ address, codecType }]
889
+ let addr = repo.byteLength - 1
890
+ while (addr >= 0) {
891
+ const code = repo.resolve(addr)
892
+ if (!code || !code.length) break
893
+ let refs
894
+ try { refs = repo.asRefs(addr) } catch { refs = null }
895
+ let childAddrs = []
896
+ if (Array.isArray(refs)) {
897
+ childAddrs = refs.filter(x => typeof x === 'number')
898
+ } else if (refs && typeof refs === 'object') {
899
+ if (Array.isArray(refs.v)) childAddrs = refs.v.filter(x => typeof x === 'number')
900
+ else childAddrs = Object.values(refs).filter(x => typeof x === 'number')
901
+ }
902
+ if (childAddrs.length) {
903
+ const codec = repo.footerToCodec[code.at(-1)]
904
+ const entry = { address: addr, codecType: codec?.type }
905
+ for (const child of childAddrs) {
906
+ if (!index.has(child)) index.set(child, [])
907
+ index.get(child).push(entry)
908
+ }
909
+ }
910
+ addr -= code.length
911
+ }
912
+ return index
913
+ }
914
+
915
+ // Walk up parent chains via the index. Internal Duple nodes are tree
916
+ // scaffolding — the user-meaningful containers are OBJECT / ARRAY /
917
+ // VARIABLE / SIGNATURE / etc. For each path that hits a non-Duple
918
+ // ancestor, accumulate that ancestor with a count of how many paths
919
+ // reach it. (Same value referenced from N different places yields N
920
+ // distinct user-level ancestors.)
921
+ function findUserReferrers (repo, targetAddr, index) {
922
+ const result = new Map() // ancestorAddr → { address, codecType, count }
923
+ function walkUp (from) {
924
+ const refs = index.get(from) ?? []
925
+ for (const r of refs) {
926
+ if (r.codecType === 'DUPLE') {
927
+ walkUp(r.address)
928
+ } else {
929
+ const existing = result.get(r.address)
930
+ if (existing) existing.count++
931
+ else result.set(r.address, { ...r, count: 1 })
932
+ }
933
+ }
934
+ }
935
+ walkUp(targetAddr)
936
+ return [...result.values()].sort((a, b) => b.address - a.address) // newest first
937
+ }
938
+
939
+ // Backwards-compat: if anyone wanted the raw direct referrers (Duples and
940
+ // all), this still works.
941
+ function findReferrers (repo, targetAddr) {
942
+ return buildReferrerIndex(repo).get(targetAddr) ?? []
943
+ }
944
+
945
+ // ── Signature verification cache ──────────────────────────────────────────
946
+ //
947
+ // repo.verify(sig, publicKey) is async. Slots render synchronously, so we
948
+ // cache results keyed by (keyHex, sigChunkAddress) and kick off the async
949
+ // verify on first encounter. When it resolves, fire() so the slot re-runs
950
+ // and the badge flips from "verifying…" to ✓ / ✗.
951
+ //
952
+ // One verify per signature per page load (~sub-ms each for secp256k1).
953
+
954
+ const verifyCache = new Map() // `${keyHex}:${addr}` → 'pending' | 'valid' | 'invalid' | { error }
955
+
956
+ function verifyStatus (repo, keyHex, sig, sigAddress) {
957
+ const cacheKey = `${keyHex}:${sigAddress}`
958
+ if (verifyCache.has(cacheKey)) return verifyCache.get(cacheKey)
959
+ verifyCache.set(cacheKey, 'pending')
960
+ repo.verify(sig, hexToBytes(keyHex))
961
+ .then(valid => { verifyCache.set(cacheKey, valid ? 'valid' : 'invalid'); fire() })
962
+ .catch(e => { verifyCache.set(cacheKey, { error: e.message }); fire() })
963
+ return 'pending'
964
+ }
965
+
966
+ // Consistent "what this is" banner at the top of every value-tab branch.
967
+ // label is the short codec/role name (e.g. "signed commit", "object",
968
+ // "duple"); content is whatever else goes in the banner (verify badge +
969
+ // label, field count, etc.); variant tints the surface — 'verified' for
970
+ // commits/sigs with a covering signature, 'unsigned' for commits awaiting
971
+ // one, undefined for everything else.
972
+ function kindBanner (label, content, variant) {
973
+ return h`
974
+ <div class=${['kind-banner', variant || null]}>
975
+ <span class="kind-label">${label}</span>
976
+ ${content || null}
977
+ </div>
978
+ `
979
+ }
980
+
981
+ function verifyLabel (status) {
982
+ if (status === 'valid') return 'verified — bytes match this repo’s public key'
983
+ if (status === 'invalid') return 'NOT VERIFIED — bytes do not match the repo key'
984
+ if (status === 'pending') return 'verifying…'
985
+ return `error: ${status?.error ?? 'unknown'}`
986
+ }
987
+
988
+ function verifyBadge (status) {
989
+ if (status === 'valid') return h`<span class="verify-badge valid" title="signature verified against repo's public key">✓</span>`
990
+ if (status === 'invalid') return h`<span class="verify-badge invalid" title="signature does NOT match repo's public key">✗</span>`
991
+ if (status === 'pending') return h`<span class="verify-badge pending" title="verifying…">…</span>`
992
+ return h`<span class="verify-badge error" title=${status?.error || 'verification error'}>⚠</span>`
993
+ }
994
+
393
995
  // Hex dump of a chunk's raw bytes. Truncates at maxLen so a giant value
394
996
  // chunk doesn't blow up the page.
395
997
  function hexDump (bytes, maxLen = 256) {
@@ -410,25 +1012,127 @@ function hexDump (bytes, maxLen = 256) {
410
1012
 
411
1013
  const appEl = document.getElementById('app')
412
1014
 
1015
+ // Wrap each view in a data-keyed <section> so mount's tag-pool recycling
1016
+ // doesn't pull stale elements from one view into another. Without this,
1017
+ // switching from registry to an at-view would recycle the registry's
1018
+ // <h2> and keep its old text children (patchElement only updates attrs).
1019
+ // The data-key changes whenever the view's identity changes (kind + the
1020
+ // params that affect rendering), forcing a fresh mount.
413
1021
  mount(h`${() => {
414
1022
  dep()
415
1023
  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 })
1024
+ case 'registry': return h`<section class="view" data-key="view-registry">${RegistryView()}</section>`
1025
+ case 'at': return h`<section class="view" data-key=${`view-at-${view.keyHex}-${view.address}`}>${AtView({ keyHex: view.keyHex, address: view.address })}</section>`
419
1026
  default: return h`<div class="empty">?</div>`
420
1027
  }
421
1028
  }}`, appEl, recaller)
422
1029
 
423
1030
  // ── Click delegation ──────────────────────────────────────────────────────
424
1031
 
1032
+ let suppressClickUntil = 0
425
1033
  appEl.addEventListener('click', e => {
1034
+ // Suppress the click that fires at the end of a drag-to-pan, so dragging
1035
+ // doesn't accidentally navigate to a chunk under the pointer when the
1036
+ // user releases.
1037
+ if (Date.now() < suppressClickUntil) return
426
1038
  const el = e.target.closest('[data-action]')
427
1039
  if (!el) return
428
1040
  switch (el.dataset.action) {
429
- case 'open-repo': return go({ kind: 'repo', keyHex: el.dataset.key })
1041
+ case 'open-repo': return go({ kind: 'at', keyHex: el.dataset.key, address: 'HEAD' })
430
1042
  case 'open-at': return go({ kind: 'at', keyHex: el.dataset.keyhex, address: +el.dataset.addr })
431
1043
  case 'back-registry': return go({ kind: 'registry' })
432
- case 'back-repo': return go({ kind: 'repo', keyHex: el.dataset.keyhex })
1044
+ case 'back-repo': return go({ kind: 'at', keyHex: el.dataset.keyhex, address: 'HEAD' })
1045
+ case 'set-tab': atTab = el.dataset.tab; return fire()
1046
+ case 'select-commit': {
1047
+ // Picking a commit is just navigation — go to /at/<sigAddress>.
1048
+ // Close the dropdown imperatively so the new view renders with
1049
+ // the selector collapsed (matches native <select> behavior).
1050
+ el.closest('details.commit-selector')?.removeAttribute('open')
1051
+ return go({ kind: 'at', keyHex: el.dataset.keyhex, address: +el.dataset.addr })
1052
+ }
433
1053
  }
434
1054
  })
1055
+
1056
+ // ── Byte-strip drag-to-pan + auto-scroll-to-HEAD ─────────────────────────
1057
+
1058
+ // On first render of a strip, scroll to the right edge (HEAD = newest
1059
+ // content). On subsequent renders, only re-pin if the user is already at
1060
+ // or near the right edge — so a live stream "follows" without dragging
1061
+ // you back if you've scrolled into history.
1062
+ function syncByteStrips () {
1063
+ for (const container of appEl.querySelectorAll('.byte-strip-container')) {
1064
+ const visible = container.clientWidth || 1
1065
+ const atRight = container.scrollLeft + visible >= container.scrollWidth - 8
1066
+ if (!container.dataset.pinned || atRight) {
1067
+ container.dataset.pinned = '1'
1068
+ container.scrollLeft = container.scrollWidth
1069
+ }
1070
+ }
1071
+ }
1072
+
1073
+ // Click-drag-to-pan inside the detail strip. Threshold of 4px before
1074
+ // treating a pointerdown as a drag — under that, fall through to the
1075
+ // regular click handler so chunk-clicks still navigate.
1076
+ let dragState = null
1077
+ appEl.addEventListener('pointerdown', e => {
1078
+ if (e.button !== undefined && e.button !== 0) return
1079
+ const container = e.target?.closest?.('.byte-strip-container')
1080
+ if (!container) return
1081
+ dragState = {
1082
+ container,
1083
+ pointerId: e.pointerId,
1084
+ startX: e.clientX,
1085
+ startScroll: container.scrollLeft,
1086
+ dragging: false
1087
+ }
1088
+ })
1089
+ appEl.addEventListener('pointermove', e => {
1090
+ if (!dragState || e.pointerId !== dragState.pointerId) return
1091
+ const dx = e.clientX - dragState.startX
1092
+ if (!dragState.dragging) {
1093
+ if (Math.abs(dx) < 4) return
1094
+ dragState.dragging = true
1095
+ dragState.container.classList.add('dragging')
1096
+ try { dragState.container.setPointerCapture(e.pointerId) } catch {}
1097
+ }
1098
+ dragState.container.scrollLeft = dragState.startScroll - dx
1099
+ e.preventDefault()
1100
+ })
1101
+ function endDrag () {
1102
+ if (!dragState) return
1103
+ if (dragState.dragging) {
1104
+ dragState.container.classList.remove('dragging')
1105
+ try { dragState.container.releasePointerCapture?.(dragState.pointerId) } catch {}
1106
+ suppressClickUntil = Date.now() + 100
1107
+ }
1108
+ dragState = null
1109
+ }
1110
+ appEl.addEventListener('pointerup', endDrag)
1111
+ appEl.addEventListener('pointercancel', endDrag)
1112
+
1113
+ // Cross-highlight: hovering any element with data-addr highlights the
1114
+ // matching chunk in the byte-map. References and referrers light up the
1115
+ // chunk's position in the stream so you can SEE where it lives. If the
1116
+ // hover came from somewhere other than the strip itself, smooth-scroll
1117
+ // the matching chunk into view inside any byte-strip-container —
1118
+ // otherwise hover-elsewhere can highlight chunks that are off-screen.
1119
+ appEl.addEventListener('mouseover', e => {
1120
+ const el = e.target.closest('[data-addr]')
1121
+ if (!el) return
1122
+ const addr = el.dataset.addr
1123
+ const matches = appEl.querySelectorAll(`.byte-map .chunk[data-addr="${addr}"]`)
1124
+ matches.forEach(c => c.classList.add('hovered'))
1125
+ if (!el.closest('.byte-strip-container')) {
1126
+ matches.forEach(c => {
1127
+ if (c.closest('.byte-strip-container')) {
1128
+ c.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
1129
+ }
1130
+ })
1131
+ }
1132
+ })
1133
+ appEl.addEventListener('mouseout', e => {
1134
+ const el = e.target.closest('[data-addr]')
1135
+ if (!el) return
1136
+ appEl.querySelectorAll('.byte-map .chunk.hovered')
1137
+ .forEach(c => c.classList.remove('hovered'))
1138
+ })