@dtudury/streamo 4.0.2 → 4.0.5

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.
@@ -58,6 +58,31 @@ function fire () {
58
58
  requestAnimationFrame(() => { stripSyncScheduled = false; syncByteStrips() })
59
59
  }
60
60
 
61
+ // Hover-only signal — separate from the bridge. Hover events that
62
+ // only set hoveredAddress fire hoverSignal exclusively; slots that
63
+ // read hoverDep() re-run, slots that don't are left alone. This is
64
+ // what keeps hovering the strip from re-rendering the strip itself.
65
+ const hoverSignal = {}
66
+ const hoverDep = () => recaller.reportKeyAccess(hoverSignal, 'data')
67
+ const hoverFire = () => recaller.reportKeyMutation(hoverSignal, 'data')
68
+
69
+ // View-shape signal — fires only when view.kind or view.keyHex
70
+ // changes. The outer mount slot watches this (NOT bridge), so
71
+ // intra-repo navigation (address changes within an at-view) does
72
+ // NOT re-run the outer slot, does NOT recreate AtView's inner slots,
73
+ // does NOT fresh-mount the byte-strip-container. Inner slots watch
74
+ // bridge — they re-run on address change, chunk arrivals, tab clicks,
75
+ // async results — and recursive-reconcile preserves the strip's DOM
76
+ // (scrollLeft, focus, keyed children) across those re-runs.
77
+ //
78
+ // Together with hoverSignal, this is the full signal decomposition:
79
+ // viewKindSignal — kind/keyHex (registry ↔ at-view, repo switch)
80
+ // bridge — chunks, address, tab, async (everything else)
81
+ // hoverSignal — strip hover preview
82
+ const viewKindSignal = {}
83
+ const viewKindDep = () => recaller.reportKeyAccess(viewKindSignal, 'data')
84
+ const viewKindFire = () => recaller.reportKeyMutation(viewKindSignal, 'data')
85
+
61
86
  // ── Hash routing ──────────────────────────────────────────────────────────
62
87
 
63
88
  function viewFromHash () {
@@ -80,15 +105,19 @@ function hashFromView (v) {
80
105
 
81
106
  let view = viewFromHash()
82
107
  function go (next) {
108
+ const kindChanged = next.kind !== view.kind || next.keyHex !== view.keyHex
83
109
  view = next
84
110
  const target = hashFromView(next)
85
111
  if (location.hash !== target) location.hash = target
112
+ if (kindChanged) viewKindFire()
86
113
  fire()
87
114
  }
88
115
  window.addEventListener('hashchange', () => {
89
116
  const next = viewFromHash()
90
117
  if (next.kind === view.kind && next.keyHex === view.keyHex && next.address === view.address) return
118
+ const kindChanged = next.kind !== view.kind || next.keyHex !== view.keyHex
91
119
  view = next
120
+ if (kindChanged) viewKindFire()
92
121
  fire()
93
122
  })
94
123
 
@@ -97,6 +126,15 @@ window.addEventListener('hashchange', () => {
97
126
  // drill-down. Reset to default on registry/repo views (set in go()).
98
127
  let atTab = 'value'
99
128
 
129
+ // Live-hover-preview state. When the user hovers a chunk on the byte
130
+ // strip, the page content below the tabs renders that chunk's value/
131
+ // storage instead of the URL's. Click to actually navigate. The header
132
+ // (selector + strip + tabs) keeps showing where you ARE; only the
133
+ // content area peeks ahead. Set by the mouseover handler when on a
134
+ // strip; cleared on mouseout. Module-level so AtView reads it inside
135
+ // the slot's reactive cell.
136
+ let hoveredAddress = null
137
+
100
138
  // ── Helpers ───────────────────────────────────────────────────────────────
101
139
 
102
140
  const truncKey = k => k.slice(0, 12) + '…'
@@ -407,75 +445,102 @@ function repoExtras (repo, keyHex) {
407
445
  `
408
446
  }
409
447
 
410
- function AtView ({ keyHex, address }) {
448
+ function AtView ({ keyHex }) {
449
+ // AtView's body is built once per repo (the outer mount slot only
450
+ // re-runs on view.kind / view.keyHex changes — see viewKindSignal).
451
+ // Anything that depends on view.address must read it fresh inside a
452
+ // reactive cell, NOT capture it in closure here. Same for resolved
453
+ // chunk lookups: each slot calls resolveContext(repo) on every run.
454
+ const resolveContext = (repo) => {
455
+ let resolvedAddr = view.address
456
+ if (view.address === 'HEAD') {
457
+ resolvedAddr = resolveHead(repo)
458
+ if (resolvedAddr === undefined) return { state: 'no-head' }
459
+ }
460
+ if (resolvedAddr >= repo.byteLength) return { state: 'loading' }
461
+ return { state: 'ok', resolvedAddr }
462
+ }
463
+
411
464
  return h`
412
465
  <a class="back" data-action="back-registry">← all repos</a>
413
466
  <div class="keyfull">
414
467
  <a class="repo-link" data-action="back-repo" data-keyhex=${keyHex}>${truncKey(keyHex)}</a>
415
- <span class="dim"> @ ${address}</span>
468
+ <span class="dim"> @ ${() => { dep(); return view.address }}</span>
416
469
  </div>
470
+
417
471
  ${() => {
472
+ // HEADER slot — bridge only. Re-runs on chunk arrivals, navigation,
473
+ // tab clicks, and async results. Does NOT re-run on hover (which
474
+ // fires hoverSignal exclusively), so hovering the strip leaves the
475
+ // selector + strip itself + tabs untouched. This is the whole
476
+ // reason the hover signal exists as a separate channel.
418
477
  dep()
419
478
  const repo = registry.get(keyHex)
420
- if (!repo) return h`<div class="empty">opening…</div>`
479
+ if (!repo) return null
480
+ const ctx = resolveContext(repo)
481
+ if (ctx.state !== 'ok') return null
482
+ const tabs = h`
483
+ <nav class="tabs">
484
+ <a class=${() => { dep(); return ['tab', atTab === 'value' ? 'active' : null] }}
485
+ data-action="set-tab" data-tab="value">value</a>
486
+ <a class=${() => { dep(); return ['tab', atTab === 'storage' ? 'active' : null] }}
487
+ data-action="set-tab" data-tab="storage">storage</a>
488
+ </nav>
489
+ `
490
+ const selector = commitSelectorSection(repo, keyHex, ctx.resolvedAddr)
491
+ const bytes = byteStreamSection(repo, keyHex, ctx.resolvedAddr)
492
+ return h`<div class="atview-header">${selector}${bytes}${tabs}</div>`
493
+ }}
421
494
 
422
- // Resolve HEAD (symbolic) to the most-recent sig address. If the
423
- // repo has no commits yet, render a useful "no HEAD" page that
424
- // still surfaces any storage chunks.
425
- let resolvedAddr = address
426
- if (address === 'HEAD') {
427
- resolvedAddr = resolveHead(repo)
428
- if (resolvedAddr === undefined) {
429
- return h`
430
- <h2>at HEAD <span class="dim">(no commits yet)</span></h2>
431
- <div class="empty">this repo doesn't have any commits yet — HEAD will resolve to the most-recent commit once one lands.</div>
432
- ${repoExtras(repo, keyHex)}
433
- `
434
- }
495
+ ${() => {
496
+ // CONTENT slot bridge + hover. Re-runs on hover (so the page
497
+ // content peeks at the hovered chunk) AND on bridge fires (for
498
+ // chunk arrivals, tab clicks, async results). Renders the tab
499
+ // body for contentAddr — the hovered address if peeking, else the
500
+ // URL's address.
501
+ dep()
502
+ hoverDep()
503
+ const repo = registry.get(keyHex)
504
+ if (!repo) return h`<div class="empty">opening…</div>`
505
+ const ctx = resolveContext(repo)
506
+ if (ctx.state === 'no-head') {
507
+ return h`
508
+ <h2>at HEAD <span class="dim">(no commits yet)</span></h2>
509
+ <div class="empty">this repo doesn't have any commits yet — HEAD will resolve to the most-recent commit once one lands.</div>
510
+ ${repoExtras(repo, keyHex)}
511
+ `
435
512
  }
436
- if (resolvedAddr >= repo.byteLength) return h`<div class="empty">loading…</div>`
513
+ if (ctx.state === 'loading') return h`<div class="empty">loading…</div>`
514
+ const resolvedAddr = ctx.resolvedAddr
515
+
516
+ // Live hover preview: contentAddr peeks at the hovered chunk;
517
+ // header still shows resolvedAddr (the URL position). Click to
518
+ // navigate.
519
+ const contentAddr = hoveredAddress != null && hoveredAddress < repo.byteLength
520
+ ? hoveredAddress
521
+ : resolvedAddr
437
522
 
438
523
  let info
439
- try { info = valueAndChildren(repo, resolvedAddr) }
524
+ try { info = valueAndChildren(repo, contentAddr) }
440
525
  catch (e) { return h`<pre class="value">decode error: ${e.message}</pre>` }
441
526
 
442
527
  const { codecType, refs, decoded } = info
443
528
  const isCommit = isCommitShape(decoded)
444
529
  const isSig = codecType === 'SIGNATURE'
445
530
 
446
- // Tabs are part of the page content (not the static header) so the
447
- // commit selector renders ABOVE the tabs. The selector is always
448
- // present (when the repo has any commits) so the UI doesn't shift
449
- // as you click between commit pages and storage drilling — when
450
- // the current address isn't a commit, the summary shows "detached".
451
- const tabs = h`
452
- <nav class="tabs">
453
- <a class=${() => { dep(); return ['tab', atTab === 'value' ? 'active' : null] }}
454
- data-action="set-tab" data-tab="value">value</a>
455
- <a class=${() => { dep(); return ['tab', atTab === 'storage' ? 'active' : null] }}
456
- data-action="set-tab" data-tab="storage">storage</a>
457
- </nav>
458
- `
459
- const selector = commitSelectorSection(repo, keyHex, resolvedAddr)
460
-
461
- // Storage tab: spatial view of where this chunk lives in the byte
462
- // stream + outgoing references + this chunk's bytes + incoming
463
- // referrers. The chunk graph from this chunk's perspective.
531
+ // Storage tab: position in stream + reachable commit, then this
532
+ // chunk's outgoing refs, raw bytes, and referrers. All for
533
+ // contentAddr (the peeked chunk during hover, otherwise the
534
+ // URL's address). Header is rendered by the sibling header slot.
464
535
  if (atTab === 'storage') {
465
536
  return h`
466
- ${selector}
467
- ${tabs}
468
- ${byteStreamSection(repo, keyHex, resolvedAddr)}
469
- ${outgoingReferencesSection(repo, keyHex, resolvedAddr)}
470
- ${rawChunkSection(repo, resolvedAddr)}
471
- ${referrersSection(repo, keyHex, resolvedAddr)}
537
+ ${chunkContextSection(repo, keyHex, contentAddr)}
538
+ ${outgoingReferencesSection(repo, keyHex, contentAddr)}
539
+ ${rawChunkSection(repo, contentAddr)}
540
+ ${referrersSection(repo, keyHex, contentAddr)}
472
541
  `
473
542
  }
474
543
 
475
- // Every value-tab branch below prepends ${selector}${tabs} so the
476
- // UI is stable across navigation: the selector is always at the
477
- // top of the page when the repo has any sigs.
478
-
479
544
  // Value tab — branches by codec.
480
545
  // Helper: render the kv-table of decoded fields for any Object/Array
481
546
  // (including commits, which are just OBJECTs with a known shape).
@@ -525,7 +590,7 @@ function AtView ({ keyHex, address }) {
525
590
  // verify state from the covering sig; the verification table at the
526
591
  // bottom links to that sig and shows its bytes.
527
592
  if (isCommit) {
528
- const covering = findCoveringSig(repo, resolvedAddr)
593
+ const covering = findCoveringSig(repo, contentAddr)
529
594
  const parentDataAddr = decoded.parent !== undefined
530
595
  ? safeGet(() => repo.decode(decoded.parent)?.dataAddress)
531
596
  : undefined
@@ -543,13 +608,35 @@ function AtView ({ keyHex, address }) {
543
608
  : h`<span class="verify-badge pending">…</span><span class="dim">not yet signed — sign in flight or pending</span>`,
544
609
  covering ? 'verified' : 'unsigned'
545
610
  )
611
+ // Commit fields render with two semantic specials: dataAddress and
612
+ // parent are *byte-address pointers* (their numeric value IS a
613
+ // navigation target), so they're clickable address pills directly
614
+ // — the chunk holding the FLOAT64 value is incidental and we
615
+ // skip the chunk-address column for those rows.
616
+ // dataAddress and parent are byte-address pointers held as
617
+ // FLOAT64 values. The → glyph signals "this number is a
618
+ // pointer; click to follow it." Without it, the address pill
619
+ // looks like any other typedValue(number) and the navigation
620
+ // hint depends on the user already knowing what these fields
621
+ // mean.
622
+ const addrLink = (addr) => addr === undefined
623
+ ? h`<span class="dim">(none — first commit)</span>`
624
+ : h`<span class="dim">→ </span><a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${addr}>@${addr}</a>`
625
+ const commitFieldsTable = h`
626
+ <table class="kv">
627
+ <tbody>
628
+ <tr><td class="mono">message</td><td>${typedValue(decoded.message)}</td></tr>
629
+ <tr><td class="mono">date</td><td>${typedValue(decoded.date)}</td></tr>
630
+ <tr><td class="mono">dataAddress</td><td>${addrLink(decoded.dataAddress)}</td></tr>
631
+ <tr><td class="mono">parent</td><td>${addrLink(decoded.parent)}</td></tr>
632
+ </tbody>
633
+ </table>
634
+ `
546
635
  return h`
547
- ${selector}
548
- ${tabs}
549
636
  ${banner}
550
- ${refsTable()}
551
- <h3>rehydrated</h3>
552
- <pre class="value">${safeJSON(decoded)}</pre>
637
+ ${commitFieldsTable}
638
+ <h3>value <span class="dim">at <a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${decoded.dataAddress}>@${decoded.dataAddress}</a></span></h3>
639
+ ${valueTree(repo, keyHex, decoded.dataAddress)}
553
640
  ${changes
554
641
  ? h`
555
642
  <h3>changed paths <span class="dim">(${changes.length})</span></h3>
@@ -581,8 +668,6 @@ function AtView ({ keyHex, address }) {
581
668
  // Duple: explain what this tree-node IS, then show its two children.
582
669
  if (codecType === 'DUPLE') {
583
670
  return h`
584
- ${selector}
585
- ${tabs}
586
671
  ${kindBanner('duple', h`<span class="dim">2-tuple, tree scaffolding</span>`)}
587
672
  <p class="explainer">
588
673
  A <strong>Duple</strong> is a 2-tuple — the building block streamo uses
@@ -610,16 +695,14 @@ function AtView ({ keyHex, address }) {
610
695
  'signature chunk',
611
696
  () => {
612
697
  dep()
613
- const status = verifyStatus(repo, keyHex, decoded, resolvedAddr)
698
+ const status = verifyStatus(repo, keyHex, decoded, contentAddr)
614
699
  return h`${verifyBadge(status)} <span class="dim">${verifyLabel(status)}</span>`
615
700
  },
616
701
  'verified'
617
702
  )
618
703
  return h`
619
- ${selector}
620
- ${tabs}
621
704
  ${banner}
622
- ${sigDetailBody(repo, keyHex, resolvedAddr, decoded)}
705
+ ${sigDetailBody(repo, keyHex, contentAddr, decoded)}
623
706
  <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>
624
707
  `
625
708
  }
@@ -635,8 +718,6 @@ function AtView ({ keyHex, address }) {
635
718
  ? (isArray ? 'empty array' : 'empty object')
636
719
  : (isArray ? 'array' : 'object')
637
720
  return h`
638
- ${selector}
639
- ${tabs}
640
721
  ${kindBanner(label, dim)}
641
722
  ${refsTable()}
642
723
  ${fieldCount > 0 ? h`
@@ -648,8 +729,6 @@ function AtView ({ keyHex, address }) {
648
729
 
649
730
  // Primitive: just show it.
650
731
  return h`
651
- ${selector}
652
- ${tabs}
653
732
  ${kindBanner(codecType.toLowerCase())}
654
733
  <pre class="value">${safeJSON(decoded)}</pre>
655
734
  `
@@ -703,12 +782,22 @@ function byteStreamSection (repo, keyHex, currentAddress) {
703
782
  const code = repo.resolve(addr)
704
783
  if (!code || !code.length) break
705
784
  const codec = repo.footerToCodec[code.at(-1)]
706
- chunks.unshift({
785
+ const chunk = {
707
786
  address: addr,
708
787
  start: addr - code.length + 1,
709
788
  length: code.length,
710
789
  codecType: codec?.type || '?'
711
- })
790
+ }
791
+ // For sigs: precompute the byte range covered, so hover anywhere on
792
+ // the page can light up that range as an overlay band on the strip.
793
+ if (chunk.codecType === 'SIGNATURE') {
794
+ try {
795
+ const sig = repo.decode(addr)
796
+ chunk.signedFrom = sig.address
797
+ chunk.signedTo = addr - code.length
798
+ } catch {}
799
+ }
800
+ chunks.unshift(chunk)
712
801
  addr -= code.length
713
802
  }
714
803
  if (!chunks.length) return null
@@ -744,6 +833,26 @@ function byteStreamSection (repo, keyHex, currentAddress) {
744
833
  return item
745
834
  })
746
835
  const stripW = cursorX
836
+ // Inspector text — codec, address, length, percentage. Defaults to
837
+ // the at-view's current chunk (currentAddress) but follows the
838
+ // hovered chunk when the user is peeking at one on the strip. The
839
+ // slot re-renders on hover (via the live-preview path), so reading
840
+ // hoveredAddress in a nested slot below — reacts to hoverDep so its
841
+ // text updates without re-rendering the strip itself.
842
+ // Map byte address → strip x. Used by the sig-coverage overlay so hover
843
+ // anywhere on the page can light up "what bytes does this sig sign".
844
+ // Stored as data attrs on the strip container so the hover handler
845
+ // can read without recomputing.
846
+ const xForByte = (byteAddr) => {
847
+ // Find the chunk containing this byte and interpolate within it.
848
+ for (const c of layout) {
849
+ if (byteAddr >= c.start && byteAddr <= c.address) {
850
+ const frac = c.length === 1 ? 0 : (byteAddr - c.start) / (c.length - 1)
851
+ return c.x + frac * c.w
852
+ }
853
+ }
854
+ return 0
855
+ }
747
856
  return h`
748
857
  <h3>byte stream <span class="dim">(${total} bytes · ${chunks.length} chunks)</span></h3>
749
858
  <div class="byte-map-legend">
@@ -756,21 +865,161 @@ function byteStreamSection (repo, keyHex, currentAddress) {
756
865
  <span class="cat-num">num</span>
757
866
  <span class="cat-var">var</span>
758
867
  </div>
759
- <div class="byte-strip-container" data-key=${`strip-${keyHex}`}>
868
+ <div class="byte-strip-container" data-key=${`strip-${keyHex}`} data-strip-w=${stripW}>
760
869
  <svg class="byte-map byte-strip" width=${stripW} height=${H} viewBox=${`0 0 ${stripW} ${H}`}>
761
870
  ${layout.map(c => {
762
871
  const cat = commitAddrs.has(c.address) ? 'commit' : codecCategory(c.codecType)
763
872
  const cls = ['chunk', `cat-${cat}`, c.address === currentAddress ? 'current' : null]
873
+ // Sigs carry their coverage range in data-attrs so hover handlers
874
+ // (anywhere on the page) can position the coverage overlay.
875
+ // Non-sigs get null which removes the attrs.
876
+ const sigFromX = c.signedFrom != null ? xForByte(c.signedFrom) : null
877
+ const sigToX = c.signedTo != null ? xForByte(c.signedTo) : null
764
878
  return h`<rect
765
879
  class=${cls}
766
880
  x=${c.x} y="0" width=${c.w} height=${H}
767
881
  data-action="open-at"
768
882
  data-keyhex=${keyHex}
769
883
  data-addr=${c.address}
884
+ data-codec=${c.codecType}
885
+ data-len=${c.length}
886
+ data-sig-from-x=${sigFromX}
887
+ data-sig-to-x=${sigToX}
770
888
  ><title>${c.codecType} @${c.address} (${c.length} bytes)</title></rect>`
771
889
  })}
890
+ <rect class="sig-coverage" x="0" y="0" width="0" height=${H} pointer-events="none"/>
772
891
  </svg>
773
892
  </div>
893
+ ${() => {
894
+ // Inspector slot — reads hoverDep so it updates as the user moves
895
+ // across the strip, but doesn't re-render the strip itself. layout,
896
+ // total, currentAddress are closed over from byteStreamSection's
897
+ // call (which only runs on bridge fires; chunk content is fixed
898
+ // per render). isPeekActive lights up the .active background only
899
+ // when the inspector is showing something other than the URL's
900
+ // chunk.
901
+ hoverDep()
902
+ const inspectorAddr = hoveredAddress != null && hoveredAddress < total
903
+ ? hoveredAddress
904
+ : currentAddress
905
+ const inspectorChunk = layout.find(c => c.address === inspectorAddr)
906
+ const inspectorText = inspectorChunk
907
+ ? `${inspectorChunk.codecType} · @${inspectorChunk.address} · ${inspectorChunk.length} bytes${total > 0 ? ` (${((inspectorChunk.length / total) * 100).toFixed(2)}% of ${total})` : ''}`
908
+ : `${chunks.length} chunks · ${total} bytes`
909
+ const isPeekActive = hoveredAddress != null && hoveredAddress !== currentAddress
910
+ return h`<div class=${['chunk-inspector', isPeekActive ? 'active' : null]}
911
+ data-key=${`inspector-${keyHex}`}>${inspectorText}</div>`
912
+ }}
913
+ `
914
+ }
915
+
916
+ // Commit reachability — the *semantic* parent of every chunk. Commits
917
+ // don't reference their data tree via asRefs; they store the address
918
+ // as a number value (a FLOAT64), and the convention "follow this
919
+ // number to find your data" is implicit. So the structural referrer
920
+ // index doesn't connect chunks to their owning commits. This walk
921
+ // makes the connection: for each commit, BFS from commit.dataAddress
922
+ // through asRefs and mark every reachable chunk. Result: chunk
923
+ // address → set of commit addresses whose dataAddress reach it.
924
+ function buildCommitReachabilityIndex (repo) {
925
+ const reach = new Map() // chunk addr → Set<commit addr>
926
+ let walk = repo.byteLength - 1
927
+ while (walk >= 0) {
928
+ const code = repo.resolve(walk)
929
+ if (!code || !code.length) break
930
+ const type = repo.footerToCodec[code.at(-1)]?.type
931
+ if (type === 'OBJECT') {
932
+ let value
933
+ try { value = repo.decode(walk) } catch {}
934
+ if (value && isCommitShape(value)) {
935
+ const visited = new Set()
936
+ const stack = [value.dataAddress]
937
+ while (stack.length) {
938
+ const a = stack.pop()
939
+ if (typeof a !== 'number' || visited.has(a)) continue
940
+ visited.add(a)
941
+ if (!reach.has(a)) reach.set(a, new Set())
942
+ reach.get(a).add(walk)
943
+ let refs
944
+ try { refs = repo.asRefs(a) } catch {}
945
+ if (Array.isArray(refs)) {
946
+ for (const c of refs) if (typeof c === 'number') stack.push(c)
947
+ } else if (refs && typeof refs === 'object' && !(refs instanceof Date) && !(refs instanceof Uint8Array)) {
948
+ if (Array.isArray(refs.v)) {
949
+ for (const c of refs.v) if (typeof c === 'number') stack.push(c)
950
+ } else {
951
+ for (const c of Object.values(refs)) if (typeof c === 'number') stack.push(c)
952
+ }
953
+ }
954
+ }
955
+ }
956
+ }
957
+ walk -= code.length
958
+ }
959
+ return reach
960
+ }
961
+
962
+ // Storage-tab context: which commits reach this chunk. (Position info
963
+ // — byte range, percentage, codec — now lives in the persistent chunk
964
+ // inspector under the byte strip, so this section focuses on the
965
+ // "story" of the chunk's place in user data.) Uses the commit-
966
+ // reachability index above, falling back to the structural referrer
967
+ // BFS for chunks not reachable from any commit (typically sigs and
968
+ // other top-level chunks).
969
+ function chunkContextSection (repo, keyHex, address) {
970
+ const reach = buildCommitReachabilityIndex(repo)
971
+ const reachingCommits = reach.get(address)
972
+ let label = null
973
+ if (reachingCommits && reachingCommits.size) {
974
+ // Show the most recent commit (highest address) reaching this chunk,
975
+ // plus a count if there are more.
976
+ const sorted = [...reachingCommits].sort((a, b) => b - a)
977
+ const newest = sorted[0]
978
+ label = h`
979
+ <a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${newest}>@${newest}</a>
980
+ ${sorted.length > 1
981
+ ? h` <span class="dim">(and ${sorted.length - 1} earlier commit${sorted.length === 2 ? '' : 's'})</span>`
982
+ : null}
983
+ `
984
+ } else {
985
+ // Fall back to the structural BFS — catches sig chunks (referenced
986
+ // by nothing in the asRefs sense, but the user might want to know
987
+ // some commit they cover).
988
+ const index = buildReferrerIndex(repo)
989
+ const visited = new Set()
990
+ let frontier = [address]
991
+ let depth = 0
992
+ while (frontier.length && depth < 64 && !label) {
993
+ const next = []
994
+ for (const a of frontier) {
995
+ if (visited.has(a)) continue
996
+ visited.add(a)
997
+ const parents = index.get(a) ?? []
998
+ for (const p of parents) {
999
+ try {
1000
+ const decoded = repo.decode(p.address)
1001
+ if (isCommitShape(decoded)) {
1002
+ label = h`<a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${p.address}>@${p.address}</a> <span class="dim">(via structural ref)</span>`
1003
+ break
1004
+ }
1005
+ } catch {}
1006
+ next.push(p.address)
1007
+ }
1008
+ if (label) break
1009
+ }
1010
+ frontier = next
1011
+ depth++
1012
+ }
1013
+ }
1014
+ if (!label) {
1015
+ label = h`<span class="dim">no commit references this chunk</span>`
1016
+ }
1017
+ return h`
1018
+ <h3>reachable from <span class="dim">user-meaningful commits</span></h3>
1019
+ <p class="dim" style="font-size: 0.85rem; margin-bottom: 0.4rem;">
1020
+ commits hold their data's location as a number-valued <code>dataAddress</code> field, so reachability isn't a structural ref — it's "starting from each commit's data, this chunk shows up."
1021
+ </p>
1022
+ <p>${label}</p>
774
1023
  `
775
1024
  }
776
1025
 
@@ -778,7 +1027,8 @@ function byteStreamSection (repo, keyHex, currentAddress) {
778
1027
  // opposed to "referenced by", which is what points to this chunk). Walks
779
1028
  // the codec's parts via repo.directReferences. Codec-by-codec — exposes
780
1029
  // the storage chain so e.g. STRING → UINT8ARRAY → DUPLE → DUPLE → … → WORD
781
- // is browsable one click at a time.
1030
+ // is browsable one click at a time. Codec column is color-coded to match
1031
+ // the byte-strip palette so the chain reads visually.
782
1032
  function outgoingReferencesSection (repo, keyHex, address) {
783
1033
  const refs = repo.directReferences(address)
784
1034
  if (!refs.length) return null
@@ -797,7 +1047,7 @@ function outgoingReferencesSection (repo, keyHex, address) {
797
1047
  return h`
798
1048
  <tr data-key=${`out${i}@${childAddr}`} data-action="open-at"
799
1049
  data-keyhex=${keyHex} data-addr=${childAddr}>
800
- <td class="mono dim">${codecType}</td>
1050
+ <td class=${['mono', 'codec-tag', `codec-${codecCategory(codecType)}`]}>${codecType}</td>
801
1051
  <td>${preview}</td>
802
1052
  <td class="mono dim">@${childAddr}</td>
803
1053
  </tr>
@@ -835,7 +1085,7 @@ function referrersSection (repo, keyHex, address) {
835
1085
  return h`
836
1086
  <tr data-key=${`r${r.address}`} data-action="open-at"
837
1087
  data-keyhex=${keyHex} data-addr=${r.address}>
838
- <td class="mono dim">${r.codecType || '?'}${r.count > 1 ? ` ×${r.count}` : ''}</td>
1088
+ <td class=${['mono', 'codec-tag', `codec-${codecCategory(r.codecType || '?')}`]}>${r.codecType || '?'}${r.count > 1 ? ` ×${r.count}` : ''}</td>
839
1089
  <td>${preview}</td>
840
1090
  <td class="mono dim">@${r.address}</td>
841
1091
  </tr>
@@ -861,38 +1111,102 @@ function isDuple (v) {
861
1111
  // count chips ({ N fields } / [ N elements ]) — depth-controlled
862
1112
  // expansion is the next step in this thread (see THREADS.md).
863
1113
  function typedValue (v, depth = 0) {
864
- if (v === null) return h`<span class="tv tv-null">null</span>`
865
- if (v === undefined) return h`<span class="tv tv-undefined">undefined</span>`
1114
+ if (v === null) return h`<span class="tv tv-null" title="NULL">null</span>`
1115
+ if (v === undefined) return h`<span class="tv tv-undefined" title="UNDEFINED">undefined</span>`
866
1116
  if (typeof v === 'boolean') {
867
- return h`<span class=${['tv', 'tv-bool', v ? 'tv-true' : 'tv-false']}>${v ? '✓' : '✗'} ${String(v)}</span>`
1117
+ return h`<span class=${['tv', 'tv-bool', v ? 'tv-true' : 'tv-false']} title=${v ? 'TRUE' : 'FALSE'}>${v ? '✓' : '✗'} ${String(v)}</span>`
868
1118
  }
869
1119
  if (typeof v === 'string') {
870
1120
  const display = v.length > 60 ? v.slice(0, 60) + '…' : v
871
- return h`<span class="tv tv-string"><span class="tv-quote">“</span>${display}<span class="tv-quote">”</span></span>`
1121
+ return h`<span class="tv tv-string" title=${v.length === 0 ? 'EMPTY_STRING' : 'STRING'}><span class="tv-quote">“</span>${display}<span class="tv-quote">”</span></span>`
872
1122
  }
873
1123
  if (typeof v === 'number') {
874
- return h`<span class="tv tv-num">${String(v)}</span>`
1124
+ // UINT7 is the codec for non-negative integers < 128; everything else
1125
+ // routes through FLOAT64. Surfacing this distinction makes "why is
1126
+ // this 1 byte vs 9" tactile when you hover.
1127
+ const codec = (Number.isInteger(v) && v >= 0 && v < 128) ? 'UINT7' : 'FLOAT64'
1128
+ return h`<span class="tv tv-num" title=${codec}>${String(v)}</span>`
875
1129
  }
876
1130
  if (v instanceof Date) {
877
- return h`<span class="tv tv-date"><span class="tv-glyph">📅</span><time datetime=${v.toISOString()}>${v.toLocaleString()}</time></span>`
1131
+ return h`<span class="tv tv-date" title="DATE"><span class="tv-glyph">📅</span><time datetime=${v.toISOString()}>${v.toLocaleString()}</time></span>`
878
1132
  }
879
1133
  if (v instanceof Uint8Array) {
880
- return h`<span class="tv tv-bytes">Uint8Array(${v.length})</span>`
1134
+ return h`<span class="tv tv-bytes" title=${v.length === 0 ? 'EMPTY_UINT8ARRAY' : (v.length <= 4 ? 'WORD or UINT8ARRAY' : 'UINT8ARRAY')}>Uint8Array(${v.length})</span>`
881
1135
  }
882
1136
  if (isDuple(v)) {
883
- if (depth > 1) return h`<span class="tv tv-duple">Duple(…)</span>`
884
- return h`<span class="tv tv-duple">Duple(${typedValue(v.v[0], depth + 1)}, ${typedValue(v.v[1], depth + 1)})</span>`
1137
+ if (depth > 1) return h`<span class="tv tv-duple" title="DUPLE">Duple(…)</span>`
1138
+ return h`<span class="tv tv-duple" title="DUPLE">Duple(${typedValue(v.v[0], depth + 1)}, ${typedValue(v.v[1], depth + 1)})</span>`
885
1139
  }
886
1140
  if (Array.isArray(v)) {
887
- return h`<span class="tv tv-array">[ ${v.length} ${v.length === 1 ? 'element' : 'elements'} ]</span>`
1141
+ return h`<span class="tv tv-array" title=${v.length === 0 ? 'EMPTY_ARRAY' : 'ARRAY'}>[ ${v.length} ${v.length === 1 ? 'element' : 'elements'} ]</span>`
888
1142
  }
889
1143
  if (typeof v === 'object') {
890
1144
  const n = Object.keys(v).length
891
- return h`<span class="tv tv-object">{ ${n} ${n === 1 ? 'field' : 'fields'} }</span>`
1145
+ return h`<span class="tv tv-object" title=${n === 0 ? 'EMPTY_OBJECT' : 'OBJECT'}>{ ${n} ${n === 1 ? 'field' : 'fields'} }</span>`
892
1146
  }
893
1147
  return h`<span class="tv">${String(v)}</span>`
894
1148
  }
895
1149
 
1150
+ // Recursive typed-value tree — like typedValue, but expands composites
1151
+ // inline up to `depth` levels deep. Beyond depth, composites render as
1152
+ // un-expanded chips. Click a chip to expand IN PLACE (forceExpanded);
1153
+ // click an expanded composite's opening bracket to collapse it back to
1154
+ // a chip (forceCollapsed). Force-expand and force-collapse override
1155
+ // the default depth-based decision.
1156
+ //
1157
+ // Default depth=3 covers `{ name, messages: [{text, at}, ...] }` —
1158
+ // outer object expanded, messages array expanded, message objects
1159
+ // expanded, and primitives like text/at render inline.
1160
+ const forceExpanded = new Set() // `${keyHex}:${address}` → user clicked chip
1161
+ const forceCollapsed = new Set() // `${keyHex}:${address}` → user clicked bracket
1162
+
1163
+ function valueTree (repo, keyHex, address, depth = 3) {
1164
+ let value, refs
1165
+ try {
1166
+ value = repo.decode(address)
1167
+ refs = repo.asRefs(address)
1168
+ } catch {
1169
+ return h`<span class="dim">(decode error @${address})</span>`
1170
+ }
1171
+ if (typeof value !== 'object' || value === null || value instanceof Date || value instanceof Uint8Array) {
1172
+ return typedValue(value)
1173
+ }
1174
+ const k = `${keyHex}:${address}`
1175
+ const userExpanded = forceExpanded.has(k)
1176
+ const userCollapsed = forceCollapsed.has(k)
1177
+ const expand = userExpanded || (!userCollapsed && depth > 0)
1178
+ if (!expand) {
1179
+ return h`<a class="tv-drill" data-action="expand-tree"
1180
+ data-keyhex=${keyHex} data-addr=${address}
1181
+ title="click to expand · drill via storage tab if you need a full at-view"
1182
+ >${typedValue(value)}</a>`
1183
+ }
1184
+ const isArray = Array.isArray(value)
1185
+ const entries = isArray
1186
+ ? value.map((v, i) => [String(i), v, refs?.[i]])
1187
+ : Object.entries(value).map(([k, v]) => [k, v, refs?.[k]])
1188
+ if (entries.length === 0) {
1189
+ return h`<span class="tv ${isArray ? 'tv-array' : 'tv-object'}">${isArray ? '[ ]' : '{ }'}</span>`
1190
+ }
1191
+ return h`
1192
+ <div class="tv-tree ${isArray ? 'tv-tree-array' : 'tv-tree-object'}">
1193
+ <span class="tv-bracket clickable" data-action="collapse-tree"
1194
+ data-keyhex=${keyHex} data-addr=${address}
1195
+ title="click to collapse"
1196
+ >${isArray ? '[' : '{'}</span>
1197
+ ${entries.map(([k, v, addr]) => h`
1198
+ <div class="tv-tree-row">
1199
+ <span class="tv-key">${k}:</span>
1200
+ ${addr !== undefined
1201
+ ? valueTree(repo, keyHex, addr, depth - 1)
1202
+ : typedValue(v)}
1203
+ </div>
1204
+ `)}
1205
+ <span class="tv-bracket">${isArray ? ']' : '}'}</span>
1206
+ </div>
1207
+ `
1208
+ }
1209
+
896
1210
  function safeGet (f) { try { return f() } catch { return undefined } }
897
1211
 
898
1212
  // Build a child→parents index for the entire repo in one pass, so we can
@@ -1027,17 +1341,24 @@ function hexDump (bytes, maxLen = 256) {
1027
1341
 
1028
1342
  const appEl = document.getElementById('app')
1029
1343
 
1030
- // Wrap each view in a data-keyed <section> so mount's tag-pool recycling
1031
- // doesn't pull stale elements from one view into another. Without this,
1032
- // switching from registry to an at-view would recycle the registry's
1033
- // <h2> and keep its old text children (patchElement only updates attrs).
1034
- // The data-key changes whenever the view's identity changes (kind + the
1035
- // params that affect rendering), forcing a fresh mount.
1344
+ // Outer mount slot. Reads viewKindDep ONLY re-runs on view.kind
1345
+ // or view.keyHex changes (registry at, or switching repos). It does
1346
+ // NOT re-run on address changes, chunk arrivals, tab clicks, or any
1347
+ // other bridge fire. That's the whole point of the decomposition:
1348
+ // keep the at-view's <section> (and the strip-container inside it)
1349
+ // alive across intra-repo navigation so click-to-navigate doesn't
1350
+ // rebuild the strip and reset its scrollLeft.
1351
+ //
1352
+ // Each view gets a data-keyed <section> so mount's matcher distinguishes
1353
+ // them — switching from registry to an at-view, or between repos, drops
1354
+ // the old section and fresh-mounts the new one. RegistryView and AtView
1355
+ // each do their own internal reactivity (inner slots reading dep() and
1356
+ // hoverDep()) for everything within a view.
1036
1357
  mount(h`${() => {
1037
- dep()
1358
+ viewKindDep()
1038
1359
  switch (view.kind) {
1039
1360
  case 'registry': return h`<section class="view" data-key="view-registry">${RegistryView()}</section>`
1040
- case 'at': return h`<section class="view" data-key=${`view-at-${view.keyHex}-${view.address}`}>${AtView({ keyHex: view.keyHex, address: view.address })}</section>`
1361
+ case 'at': return h`<section class="view" data-key=${`view-at-${view.keyHex}`}>${AtView({ keyHex: view.keyHex })}</section>`
1041
1362
  default: return h`<div class="empty">?</div>`
1042
1363
  }
1043
1364
  }}`, appEl, recaller)
@@ -1065,6 +1386,18 @@ appEl.addEventListener('click', e => {
1065
1386
  el.closest('details.commit-selector')?.removeAttribute('open')
1066
1387
  return go({ kind: 'at', keyHex: el.dataset.keyhex, address: +el.dataset.addr })
1067
1388
  }
1389
+ case 'expand-tree': {
1390
+ const k = `${el.dataset.keyhex}:${el.dataset.addr}`
1391
+ forceExpanded.add(k)
1392
+ forceCollapsed.delete(k)
1393
+ return fire()
1394
+ }
1395
+ case 'collapse-tree': {
1396
+ const k = `${el.dataset.keyhex}:${el.dataset.addr}`
1397
+ forceCollapsed.add(k)
1398
+ forceExpanded.delete(k)
1399
+ return fire()
1400
+ }
1068
1401
  }
1069
1402
  })
1070
1403
 
@@ -1078,8 +1411,12 @@ function syncByteStrips () {
1078
1411
  for (const container of appEl.querySelectorAll('.byte-strip-container')) {
1079
1412
  const visible = container.clientWidth || 1
1080
1413
  const atRight = container.scrollLeft + visible >= container.scrollWidth - 8
1081
- if (!container.dataset.pinned || atRight) {
1082
- container.dataset.pinned = '1'
1414
+ // Auto-pin to HEAD only when the strip is freshly mounted (scroll
1415
+ // hasn't been touched yet, so scrollLeft === 0). For recycled strips
1416
+ // mount restores the previous scrollLeft, so this branch correctly
1417
+ // skips and the user's position is preserved. Live updates still
1418
+ // pin if the user is already at the right edge (atRight).
1419
+ if (container.scrollLeft === 0 || atRight) {
1083
1420
  container.scrollLeft = container.scrollWidth
1084
1421
  }
1085
1422
  }
@@ -1126,11 +1463,11 @@ appEl.addEventListener('pointerup', endDrag)
1126
1463
  appEl.addEventListener('pointercancel', endDrag)
1127
1464
 
1128
1465
  // Cross-highlight: hovering any element with data-addr highlights the
1129
- // matching chunk in the byte-map. References and referrers light up the
1130
- // chunk's position in the stream so you can SEE where it lives. If the
1131
- // hover came from somewhere other than the strip itself, smooth-scroll
1132
- // the matching chunk into view inside any byte-strip-container —
1133
- // otherwise hover-elsewhere can highlight chunks that are off-screen.
1466
+ // matching chunk in the byte-map, populates the chunk inspector below
1467
+ // the strip with codec/addr/length, and (if the hovered chunk is a
1468
+ // signature) lights up its covered byte range as an overlay band on
1469
+ // the strip. If the hover came from somewhere other than the strip
1470
+ // itself, smooth-scroll the matching chunk into view in the strip.
1134
1471
  appEl.addEventListener('mouseover', e => {
1135
1472
  const el = e.target.closest('[data-addr]')
1136
1473
  if (!el) return
@@ -1144,10 +1481,46 @@ appEl.addEventListener('mouseover', e => {
1144
1481
  }
1145
1482
  })
1146
1483
  }
1484
+ // Look up the chunk's data on the strip rect for sig-coverage overlay.
1485
+ const stripRect = matches[0]?.closest('.byte-strip-container') ? matches[0]
1486
+ : appEl.querySelector(`.byte-strip .chunk[data-addr="${addr}"]`)
1487
+ if (stripRect) {
1488
+ const fromX = stripRect.getAttribute('data-sig-from-x')
1489
+ const toX = stripRect.getAttribute('data-sig-to-x')
1490
+ if (fromX != null && toX != null) {
1491
+ const overlay = stripRect.closest('.byte-strip').querySelector('.sig-coverage')
1492
+ if (overlay) {
1493
+ overlay.setAttribute('x', fromX)
1494
+ overlay.setAttribute('width', String(parseFloat(toX) - parseFloat(fromX)))
1495
+ overlay.classList.add('active')
1496
+ }
1497
+ }
1498
+ }
1499
+ // Live preview: if the hovered chunk is on the byte strip, set
1500
+ // hoveredAddress so the page content below renders for that chunk.
1501
+ // Click to actually navigate. Only fire if the address changed —
1502
+ // moving within the same chunk shouldn't re-render.
1503
+ const onStrip = el.closest('.byte-strip-container')
1504
+ const newHover = onStrip ? +addr : null
1505
+ if (newHover !== hoveredAddress) {
1506
+ hoveredAddress = newHover
1507
+ hoverFire()
1508
+ }
1147
1509
  })
1148
1510
  appEl.addEventListener('mouseout', e => {
1149
1511
  const el = e.target.closest('[data-addr]')
1150
1512
  if (!el) return
1151
- appEl.querySelectorAll('.byte-map .chunk.hovered')
1152
- .forEach(c => c.classList.remove('hovered'))
1513
+ appEl.querySelectorAll('.byte-map .chunk.hovered').forEach(c => c.classList.remove('hovered'))
1514
+ appEl.querySelectorAll('.sig-coverage.active').forEach(o => o.classList.remove('active'))
1515
+ // Clear hoveredAddress unless the cursor is moving to ANOTHER chunk
1516
+ // on the strip. The previous check ("still inside .byte-strip-container")
1517
+ // treated the direction labels and any blank-space as "still hovering,"
1518
+ // which left the page stuck on the previously hovered chunk's content.
1519
+ // Requiring .chunk[data-addr] specifically means moving off a chunk
1520
+ // anywhere — out of the strip OR to its non-chunk regions — reverts.
1521
+ const goingToChunk = e.relatedTarget?.closest?.('.byte-strip-container .chunk[data-addr]')
1522
+ if (!goingToChunk && hoveredAddress !== null) {
1523
+ hoveredAddress = null
1524
+ hoverFire()
1525
+ }
1153
1526
  })