@dtudury/streamo 4.0.0 → 4.0.2

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.
package/README.md CHANGED
@@ -126,6 +126,29 @@ const registry = new RepoRegistry(async key => {
126
126
  const repo = await registry.open(publicKeyHex)
127
127
  ```
128
128
 
129
+ ### bridgeRegistry — connect a multi-repo registry to your app's Recaller
130
+
131
+ Each `Repo` owns its own `Recaller` (so it can do fine-grained tracking on its
132
+ own internal keys), and your app uses a separate `Recaller` for its `mount()`
133
+ slots. Reading `repo.byteLength` inside a slot registers a dep on the *repo's*
134
+ recaller, not the app's, so without an explicit bridge the slot would never
135
+ re-run when chunks arrive. `bridgeRegistry` is that bridge:
136
+
137
+ ```js
138
+ import { Recaller, bridgeRegistry, h, mount } from '@dtudury/streamo'
139
+
140
+ const recaller = new Recaller('app')
141
+ const { dep, fire } = bridgeRegistry(registry, recaller)
142
+
143
+ mount(h`${() => {
144
+ dep()
145
+ for (const [k, r] of registry) ... // freely read any repo's state
146
+ }}`, appEl, recaller)
147
+
148
+ // Non-repo state changes (route, async results) — call fire() to force a re-render.
149
+ window.addEventListener('hashchange', fire)
150
+ ```
151
+
129
152
  ### registrySync — peer sync over WebSocket
130
153
 
131
154
  ```js
@@ -163,7 +186,7 @@ mount(h`
163
186
  `, document.body, recaller)
164
187
  ```
165
188
 
166
- Functions interpolated as `${() => ...}` are reactive cells — they re-run automatically whenever the data they read changes. No virtual DOM diffing; only the exact DOM nodes bound to changed data update. Elements are recycled across re-renders by `data-key` (or tag as a fallback), so user input and focus survive list reorders. SVG namespaces propagate automatically — `` h`<svg><path d="..."/></svg>` `` works without any extra wiring. `class` accepts an array (`['btn', isActive && 'active']`) or an object (`{btn: true, active: false}`); falsy entries are filtered out.
189
+ Functions interpolated as `${() => ...}` are reactive cells — they re-run automatically whenever the data they read changes. Only the exact DOM nodes bound to changed data update. Elements with stable `data-key` are recycled across re-renders so the outer element's identity and document position survive (helpful for animations or external DOM references). The recycled element's inner content is rebuilt from the new vnode on each re-render, so static interpolations (`${value}`) reflect current state. SVG namespaces propagate automatically — `` h`<svg><path d="..."/></svg>` `` works without any extra wiring. `class` accepts an array (`['btn', isActive && 'active']`) or an object (`{btn: true, active: false}`); falsy entries are filtered out.
167
190
 
168
191
  > **For lists that can reorder**, always set `data-key` on each item — the unkeyed positional fallback will recycle elements by tag in document order, which can attach the wrong DOM node (and any user focus/input on it) to the wrong vnode after a reorder.
169
192
 
package/index.js CHANGED
@@ -17,6 +17,7 @@ export { Repo } from './public/streamo/Repo.js'
17
17
  export { Signer, verifySignature } from './public/streamo/Signer.js'
18
18
  export { Signature } from './public/streamo/Signature.js'
19
19
  export { RepoRegistry } from './public/streamo/RepoRegistry.js'
20
+ export { bridgeRegistry } from './public/streamo/bridgeRegistry.js'
20
21
  export { registrySync, handleRegistryPeer } from './public/streamo/registrySync.js'
21
22
  export { archiveSync } from './public/streamo/archiveSync.js'
22
23
  export { fileSync } from './public/streamo/fileSync.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtudury/streamo",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
4
4
  "description": "peer-to-peer sync where your data and identity belong to you, not the server",
5
5
  "keywords": ["p2p", "peer-to-peer", "sync", "reactive", "content-addressed", "websocket", "signed", "append-only", "offline-first", "cryptographic", "identity"],
6
6
  "repository": "git@github.com:dtudury/streamo.git",
@@ -4,6 +4,7 @@ import { Recaller } from '../../streamo/utils/Recaller.js'
4
4
  import { Signer } from '../../streamo/Signer.js'
5
5
  import { RepoRegistry } from '../../streamo/RepoRegistry.js'
6
6
  import { registrySync } from '../../streamo/registrySync.js'
7
+ import { bridgeRegistry } from '../../streamo/bridgeRegistry.js'
7
8
  import { bytesToHex } from '../../streamo/utils.js'
8
9
 
9
10
  const { primaryKeyHex: rootKey } = await fetch('/api/info').then(r => r.json())
@@ -13,8 +14,10 @@ function fmt (ts) {
13
14
  }
14
15
 
15
16
  function Msg ({ name, text, at, mine }) {
17
+ // +at coerces both Date and number to ms — stable key across old (number)
18
+ // and new (Date) message records as we transition.
16
19
  return h`
17
- <div class=${['msg', mine ? 'mine' : 'theirs']} data-key=${at}>
20
+ <div class=${['msg', mine ? 'mine' : 'theirs']} data-key=${+at}>
18
21
  ${!mine ? h`<div class="sender">${name}</div>` : null}
19
22
  <div class="text">${text}</div>
20
23
  <div class="time">${fmt(at)}</div>
@@ -79,30 +82,25 @@ joinBtn.onclick = async () => {
79
82
  // ── Reactive message list ──────────────────────────────────────────────
80
83
  //
81
84
  // Each repo has its own internal Recaller, so repo.get() inside a mount
82
- // slot won't automatically re-trigger mount's recaller. Bridge via
83
- // reportKey*: repo.watch() calls reportKeyMutation when data changes;
84
- // the slot calls reportKeyAccess to register the dependency.
85
+ // slot doesn't automatically re-trigger mount's recaller. bridgeRegistry
86
+ // wires every repo (existing and future) into a single signal on the
87
+ // chat recaller; dep() inside the slot subscribes to it. See design.md
88
+ // §6 for the cross-recaller pattern.
85
89
 
86
90
  const recaller = new Recaller('chat')
87
- const signal = {}
88
-
89
- function triggerRender () {
90
- recaller.reportKeyMutation(signal, 'data')
91
+ const { dep } = bridgeRegistry(registry, recaller, 'chat')
92
+
93
+ // Auto-scroll to the bottom whenever any chunk arrives. Subscribing
94
+ // via the same `dep` keeps it in lockstep with the mount slot — both
95
+ // re-run when the bridge fires, the slot updates the DOM, and this
96
+ // watcher schedules a post-layout scroll.
97
+ recaller.watch('chat-scroll', () => {
98
+ dep()
91
99
  requestAnimationFrame(() => { msgsEl.scrollTop = msgsEl.scrollHeight })
92
- }
93
-
94
- function watchRepo (keyHex, repo) {
95
- repo.watch(`chat:${keyHex}`, () => {
96
- repo.byteLength // register 'length' dep → re-fires on every commit and incoming sync chunk
97
- triggerRender()
98
- })
99
- }
100
-
101
- for (const [k, r] of registry) watchRepo(k, r)
102
- registry.onOpen((keyHex, repo) => { watchRepo(keyHex, repo); triggerRender() })
100
+ })
103
101
 
104
102
  mount(h`${function messages () {
105
- recaller.reportKeyAccess(signal, 'data')
103
+ dep()
106
104
  const all = []
107
105
  for (const [keyHex, repo] of registry) {
108
106
  if (keyHex === rootKey) continue
@@ -129,7 +127,7 @@ joinBtn.onclick = async () => {
129
127
  const messages = myRepo.get('messages') ?? []
130
128
  const preview = text.length > 50 ? text.slice(0, 50).trim() + '…' : text
131
129
  myRepo.defaultMessage = `"${preview}" (web)`
132
- myRepo.set({ name: username, messages: [...messages, { text, at: Date.now() }] })
130
+ myRepo.set({ name: username, messages: [...messages, { text, at: new Date() }] })
133
131
  }
134
132
 
135
133
  sendBtn.onclick = sendMessage
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>streamo explorer</title>
7
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22><text y=%2214%22 font-size=%2214%22>🌊</text></svg>">
7
8
  <link rel="stylesheet" href="/apps/styles/proto.css">
8
9
  <style>
9
10
  body { max-width: 60rem; margin: 0 auto; padding: 2rem 1.25rem; }
@@ -51,10 +52,10 @@
51
52
  text-align: center;
52
53
  align-self: center;
53
54
  }
54
- .row.commit .kind { color: var(--accent); border-color: var(--accent); }
55
- .row.signature .kind { color: var(--warn); border-color: var(--warn); }
56
- .row.signed-commit .kind { color: #16a34a; border-color: #16a34a; }
57
- .row.unsigned-commit .kind { color: var(--warn); border-color: var(--warn); }
55
+ .row.commit .kind { color: var(--accent); border-color: var(--accent); }
56
+ .row.signature .kind { color: var(--warn); border-color: var(--warn); }
57
+ .row.signed-commit .kind { color: #16a34a; border-color: #16a34a; }
58
+ .row.signed-commit.unsigned .kind { color: var(--ink-dim); border-color: var(--ink-dim); }
58
59
 
59
60
  /* HEAD card — the most-recent signed commit, prominent and self-orienting. */
60
61
  .row.signed-commit.head-card {
@@ -64,6 +65,22 @@
64
65
  }
65
66
  .row.signed-commit.head-card .msg { font-size: 1rem; font-weight: 500; }
66
67
 
68
+ /* Detached card — same layout as the head-card but neutral styling.
69
+ Shown as the selector summary when the current address isn't a sig
70
+ (you've drilled into raw memory). The dropdown body is still the
71
+ way back — pick a real commit and you re-attach. */
72
+ .row.signed-commit.detached-card {
73
+ border: 1.5px dashed var(--rule);
74
+ background: transparent;
75
+ padding: 0.85rem;
76
+ cursor: pointer;
77
+ }
78
+ .row.signed-commit.detached-card .kind {
79
+ color: var(--ink-dim);
80
+ border-color: var(--ink-dim);
81
+ }
82
+ .row.signed-commit.detached-card .msg { font-size: 0.95rem; }
83
+
67
84
  /* Commit selector: a real dropdown widget. Summary = currently-selected
68
85
  commit (HEAD by default), styled as the green head-card. Body =
69
86
  full list of signed commits, with the selected one marked. */
@@ -113,17 +130,25 @@
113
130
  }
114
131
  details.other-storage[open] > summary { color: var(--ink); }
115
132
 
116
- /* Polished signed-commit detail view (AtView SIGNATURE branch). */
117
- .signed-commit-banner {
133
+ /* "What this is" banner top of every value tab. Default neutral
134
+ border for storage codecs; green .verified for commits or sigs
135
+ backed by a valid signature; dim .unsigned for commits awaiting
136
+ a signature. */
137
+ .kind-banner {
118
138
  display: flex; align-items: center; gap: 0.5rem;
119
139
  padding: 0.65rem 0.85rem; margin: 0.5rem 0 1rem;
120
- border: 1.5px solid #16a34a; border-radius: var(--radius);
140
+ border: 1.5px solid var(--rule); border-radius: var(--radius);
141
+ }
142
+ .kind-banner.verified {
143
+ border-color: #16a34a;
121
144
  background: rgba(22, 163, 74, 0.06);
122
145
  }
123
- .signed-commit-banner .signed-label {
146
+ .kind-banner.unsigned { border-style: dashed; }
147
+ .kind-banner .kind-label {
124
148
  font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em;
125
- font-weight: 600; color: #16a34a;
149
+ font-weight: 600; color: var(--ink-dim);
126
150
  }
151
+ .kind-banner.verified .kind-label { color: #16a34a; }
127
152
  .commit-card {
128
153
  padding: 0.6rem 0.85rem; margin: 0.4rem 0;
129
154
  border: 1px solid var(--rule); border-radius: var(--radius);
@@ -224,6 +249,33 @@
224
249
  .byte-map .chunk.current { stroke: var(--ink); stroke-width: 2; }
225
250
  .byte-map .chunk.hovered { fill-opacity: 0.55; }
226
251
 
252
+ /* Streamo-typed value pills — every value gets a type-specific visual
253
+ identity instead of flattening through JSON.stringify. Colors echo
254
+ the byte-strip codec palette below so the visual language carries
255
+ across the page. */
256
+ .tv {
257
+ display: inline-flex;
258
+ align-items: center;
259
+ gap: 0.25rem;
260
+ padding: 0.05rem 0.4rem;
261
+ border-radius: var(--radius);
262
+ font-size: 0.85rem;
263
+ max-width: 100%;
264
+ vertical-align: baseline;
265
+ }
266
+ .tv-string { color: #047857; background: rgba(16, 185, 129, 0.10); font-family: monospace; }
267
+ .tv-string .tv-quote { color: #10b981; opacity: 0.7; font-weight: 600; }
268
+ .tv-num { color: #475569; background: rgba(100, 116, 139, 0.10); font-family: monospace; }
269
+ .tv-date { color: #475569; background: rgba(100, 116, 139, 0.10); }
270
+ .tv-date .tv-glyph { font-size: 0.75rem; }
271
+ .tv-date time { font-variant-numeric: tabular-nums; }
272
+ .tv-bool.tv-true { color: #15803d; background: rgba(22, 163, 74, 0.10); font-family: monospace; }
273
+ .tv-bool.tv-false { color: #b91c1c; background: rgba(220, 38, 38, 0.10); font-family: monospace; }
274
+ .tv-null, .tv-undefined { color: var(--ink-dim); background: transparent; font-style: italic; font-family: monospace; }
275
+ .tv-bytes { color: #4d7c0f; background: rgba(132, 204, 22, 0.10); font-family: monospace; }
276
+ .tv-array, .tv-object { color: #1e40af; background: rgba(59, 130, 246, 0.10); }
277
+ .tv-duple { color: #6b21a8; background: rgba(168, 85, 247, 0.10); }
278
+
227
279
  /* codec category palette — used in both the legend and the SVG fills */
228
280
  .cat-commit { fill: #f59e0b; background: #f59e0b; }
229
281
  .cat-sig { fill: #ef4444; background: #ef4444; }
@@ -13,15 +13,16 @@
13
13
  // storage chunks tucked into a <details>). Otherwise it's storage
14
14
  // drilling — value/storage tabs for that chunk, no selector.
15
15
  //
16
- // State lives in plain JS variables; reactivity is bridged from each Repo's
17
- // internal Recaller into the app-level Recaller via the `signal` pattern
18
- // (see chat/main.js for the same approach).
16
+ // Reactivity is bridged from each Repo's internal Recaller into the
17
+ // app-level Recaller via bridgeRegistry see design.md §6 for why
18
+ // each Repo has its own Recaller and how the bridge connects them.
19
19
 
20
20
  import { h } from '../../streamo/h.js'
21
21
  import { mount } from '../../streamo/mount.js'
22
22
  import { Recaller } from '../../streamo/utils/Recaller.js'
23
23
  import { RepoRegistry } from '../../streamo/RepoRegistry.js'
24
24
  import { registrySync } from '../../streamo/registrySync.js'
25
+ import { bridgeRegistry } from '../../streamo/bridgeRegistry.js'
25
26
  import { changedPaths } from '../../streamo/Streamo.js'
26
27
  import { hexToBytes } from '../../streamo/utils.js'
27
28
 
@@ -44,37 +45,19 @@ try {
44
45
  // ── App-level reactivity ──────────────────────────────────────────────────
45
46
 
46
47
  const recaller = new Recaller('explorer')
47
- const signal = {}
48
- const dep = () => recaller.reportKeyAccess(signal, 'data')
48
+ const { dep, fire: bridgeFire } = bridgeRegistry(registry, recaller, 'explorer')
49
49
 
50
- const schedule = typeof requestAnimationFrame !== 'undefined'
51
- ? fn => requestAnimationFrame(fn)
52
- : fn => queueMicrotask(fn)
53
- let scheduled = false
50
+ // Wrap bridgeFire to also schedule the byte-strip pin-to-HEAD side effect
51
+ // after the next render. Reactive mutation is synchronous (so the slot
52
+ // re-runs at next tick); only the post-render DOM peek goes through rAF.
53
+ let stripSyncScheduled = false
54
54
  function fire () {
55
- if (scheduled) return
56
- scheduled = true
57
- schedule(() => {
58
- scheduled = false
59
- recaller.reportKeyMutation(signal, 'data')
60
- // After mount has updated the DOM, sync byte-strip viewport indicators
61
- // and (if appropriate) keep them pinned to HEAD on live updates.
62
- syncByteStrips()
63
- })
55
+ bridgeFire()
56
+ if (stripSyncScheduled) return
57
+ stripSyncScheduled = true
58
+ requestAnimationFrame(() => { stripSyncScheduled = false; syncByteStrips() })
64
59
  }
65
60
 
66
- const watched = new Set()
67
- function watchRepo (key, repo) {
68
- if (watched.has(key)) return
69
- watched.add(key)
70
- repo.watch(`explorer:${key}`, () => {
71
- repo.byteLength
72
- fire()
73
- })
74
- }
75
- for (const [k, r] of registry) watchRepo(k, r)
76
- registry.onOpen((k, r) => { watchRepo(k, r); fire() })
77
-
78
61
  // ── Hash routing ──────────────────────────────────────────────────────────
79
62
 
80
63
  function viewFromHash () {
@@ -134,58 +117,49 @@ function safeJSON (value) {
134
117
  }, 2)
135
118
  }
136
119
 
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 elseDuples, OBJECTs that
146
- // aren't commits, ARRAYs, STRINGs, etc. — is 'other' and lives in the
147
- // storage section.
148
- function * signedCommits (repo) {
120
+ // Walk every chunk newest-first, yielding one entry per commit (with
121
+ // its covering signature attached) and one 'other' entry per non-commit
122
+ // non-sig chunk. A signature is part of *how* a commit is verified, not
123
+ // a thing of its own so the user-level unit is the commit. Walking
124
+ // newest-first, we encounter each sig before the commits it covers
125
+ // (sig has higher address than the bytes it signed); we track the
126
+ // most-recently-seen sig and attach it to subsequent commits as their
127
+ // 'covering'. Commits encountered before any sig are uncovered (sign
128
+ // in flight or none yet)those have covering: null.
129
+ function * commitsNewestFirst (repo) {
149
130
  const len = repo.byteLength
150
131
  if (len <= 0) return
151
132
  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
-
133
+ let covering = null // most-recent sig encountered in this walk
160
134
  while (addr >= 0) {
161
135
  const code = repo.resolve(addr)
162
136
  if (!code || !code.length) break
163
137
  const type = repo.footerToCodec[code.at(-1)]?.type
164
-
165
138
  if (type === 'SIGNATURE') {
166
- for (const e of flush()) yield e
167
139
  let sig
168
140
  try { sig = repo.decode(addr) } catch { sig = null }
169
- pendingSig = sig
170
- ? {
171
- sigAddress: addr,
172
- signedFrom: sig.address,
173
- signedTo: addr - code.length,
174
- sigHex: truncHex(sig.compactRawBytes, 12)
175
- }
176
- : null
177
- pendingCommits = []
141
+ if (sig) {
142
+ covering = {
143
+ sigAddress: addr,
144
+ signedFrom: sig.address,
145
+ signedTo: addr - code.length,
146
+ sigHex: truncHex(sig.compactRawBytes, 12)
147
+ }
148
+ }
149
+ yield { kind: 'sig', address: addr, codecType: type }
178
150
  } else if (type === 'OBJECT') {
179
151
  let value
180
152
  try { value = repo.decode(addr) } catch { value = null }
181
153
  if (isCommitShape(value)) {
182
- pendingCommits.push({
154
+ yield {
155
+ kind: 'commit',
183
156
  address: addr,
184
157
  message: value.message,
185
158
  date: value.date,
186
159
  dataAddress: value.dataAddress,
187
- parent: value.parent
188
- })
160
+ parent: value.parent,
161
+ covering
162
+ }
189
163
  } else {
190
164
  yield { kind: 'other', address: addr, codecType: type }
191
165
  }
@@ -194,67 +168,47 @@ function * signedCommits (repo) {
194
168
  }
195
169
  addr -= code.length
196
170
  }
197
- for (const e of flush()) yield e
198
171
  }
199
172
 
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>` }
173
+ // Find the covering signature for a commit — the first signature chunk
174
+ // newer than the commit whose [signedFrom, signedTo] range includes its
175
+ // address. Returns { sigAddress, signedFrom, signedTo, decoded } or null
176
+ // if the commit is uncovered (sign in flight or pending).
177
+ function findCoveringSig (repo, commitAddr) {
178
+ let scan = repo.byteLength - 1
179
+ while (scan > commitAddr) {
180
+ const code = repo.resolve(scan)
181
+ if (!code || !code.length) break
182
+ if (repo.footerToCodec[code.at(-1)]?.type === 'SIGNATURE') {
183
+ let sig
184
+ try { sig = repo.decode(scan) } catch { sig = null }
185
+ if (sig && sig.address <= commitAddr && (scan - code.length) >= commitAddr) {
186
+ return { sigAddress: scan, signedFrom: sig.address, signedTo: scan - code.length, decoded: sig }
187
+ }
188
+ }
189
+ scan -= code.length
190
+ }
191
+ return null
192
+ }
193
+
194
+ // Sig-detail view — when you're at a sig chunk directly (e.g., from
195
+ // drilling through storage). Sigs are auxiliary in the new model — the
196
+ // user-level unit is the commit — so this page shows the sig's content
197
+ // without trying to be the "polished signed commit" page. The kindBanner
198
+ // is rendered by the caller (AtView) so its variant matches the rest of
199
+ // the value-tab branches.
200
+ function sigDetailBody (repo, keyHex, sigAddress, decoded) {
208
201
  const chunk = repo.resolve(sigAddress)
209
202
  const chunkLen = chunk.length
210
203
  const signedTo = sigAddress - chunkLen
211
204
  const sigChunkStart = sigAddress - chunkLen + 1
212
205
  const covered = commitsCoveredBySignature(repo, decoded.address, signedTo)
213
- const head = covered[0]
214
206
  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
207
  <table class="kv">
252
208
  <tbody>
253
209
  <tr>
254
210
  <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>
211
+ <td>@${decoded.address} through @${signedTo} (${signedTo - decoded.address + 1} bytes)</td>
258
212
  </tr>
259
213
  <tr>
260
214
  <td>sig chunk</td>
@@ -263,6 +217,19 @@ function signedCommitDetail (repo, keyHex, sigAddress) {
263
217
  <tr><td>bytes</td><td class="mono">${truncHex(decoded.compactRawBytes, 32)}</td></tr>
264
218
  </tbody>
265
219
  </table>
220
+ ${covered.length ? h`
221
+ <h3>commits in this signature ${covered.length > 1 ? h`<span class="dim">(${covered.length}, batched in one sign)</span>` : null}</h3>
222
+ ${covered.map(c => h`
223
+ <div class="commit-card" data-key=${`cc${c.address}`} data-action="open-at"
224
+ data-keyhex=${keyHex} data-addr=${c.address}>
225
+ <div class="commit-msg">${c.message || h`<span class="dim">(no message)</span>`}</div>
226
+ <div class="commit-meta dim">
227
+ <span>${fmtDate(c.date)}</span>
228
+ <span> · @${c.address}</span>
229
+ </div>
230
+ </div>
231
+ `)}
232
+ ` : null}
266
233
  `
267
234
  }
268
235
 
@@ -314,11 +281,18 @@ function RegistryView () {
314
281
  dep()
315
282
  const rows = []
316
283
  for (const [keyHex, repo] of registry) {
284
+ // No claims about state we can't verify — show the date when we
285
+ // resolve a commit, otherwise show the byte count. byteLength
286
+ // is honest: it's what we actually have on hand. The watcher
287
+ // fires as more chunks land and the row settles to a date once
288
+ // the commit chunk resolves at the end of the stream.
317
289
  const last = repo.lastCommit
290
+ const len = repo.byteLength
291
+ const when = last ? fmtDate(last.date) : `${len} b`
318
292
  rows.push(h`
319
293
  <div class="row" data-key=${keyHex} data-action="open-repo">
320
294
  <span class="mono">${truncKey(keyHex)}</span>
321
- <span class="when">${last ? fmtDate(last.date) : '(no commits)'}</span>
295
+ <span class=${['when', last ? null : 'dim']}>${when}</span>
322
296
  <span class="msg dim">${last?.message || ''}</span>
323
297
  </div>
324
298
  `)
@@ -328,101 +302,108 @@ function RegistryView () {
328
302
  `
329
303
  }
330
304
 
331
- // Resolve the symbolic HEAD address to the most-recent sig chunk's
332
- // address. Returns undefined if the repo has no signatures yet.
305
+ // Resolve the symbolic HEAD address to the most-recent COMMIT chunk's
306
+ // address not the most-recent signature. The user-level unit is the
307
+ // commit; sigs are how it's verified, but HEAD-as-a-commit is what
308
+ // people mean by "the latest." Returns undefined if there are no commits.
333
309
  function resolveHead (repo) {
334
310
  let walk = repo.byteLength - 1
335
311
  while (walk >= 0) {
336
312
  const code = repo.resolve(walk)
337
313
  if (!code || !code.length) break
338
- if (repo.footerToCodec[code.at(-1)]?.type === 'SIGNATURE') return walk
314
+ if (repo.footerToCodec[code.at(-1)]?.type === 'OBJECT') {
315
+ let value
316
+ try { value = repo.decode(walk) } catch { value = null }
317
+ if (isCommitShape(value)) return walk
318
+ }
339
319
  walk -= code.length
340
320
  }
341
321
  return undefined
342
322
  }
343
323
 
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')
324
+ // Commit selector dropdown — always rendered at the top of an at-view
325
+ // when the repo has any commits. The dropdown enumerates COMMITS (not
326
+ // sigs), since the commit is the user-level unit. Each entry's verify
327
+ // badge comes from its covering sig; uncovered commits show a "pending"
328
+ // badge. When the current address is a commit, that row is the summary;
329
+ // otherwise the summary is a "detached" card (you're at a sig chunk, a
330
+ // Duple, raw bytes, etc. drill state, not a named ref).
331
+ function commitSelectorSection (repo, keyHex, currentAddr) {
332
+ const entries = [...commitsNewestFirst(repo)].filter(e => e.kind === 'commit')
351
333
  if (!entries.length) return null
352
334
  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 || ''}`
335
+ const commitRow = (c, tag, { asSummary = false, isSelected = false } = {}) => {
360
336
  const cls = ['row', 'signed-commit',
361
337
  asSummary ? 'head-card' : null,
362
- isSelected ? 'selected' : null]
338
+ isSelected ? 'selected' : null,
339
+ c.covering ? null : 'unsigned']
363
340
  const action = asSummary ? null : 'select-commit'
341
+ const badge = () => {
342
+ dep()
343
+ if (!c.covering) return h`<span class="verify-badge pending" title="not yet signed">…</span>`
344
+ return verifyBadge(verifyStatus(repo, keyHex, c.covering.decoded || repo.decode(c.covering.sigAddress), c.covering.sigAddress))
345
+ }
364
346
  return h`
365
347
  <div class=${cls}
366
- data-key=${`sc${e.sigAddress}`}
348
+ data-key=${`c${c.address}`}
367
349
  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>
350
+ data-keyhex=${keyHex} data-addr=${c.address}>
351
+ <span class="kind">${tag} ${badge}</span>
352
+ <span class="msg">${c.message || h`<span class="dim">(no message)</span>`}</span>
353
+ <span class="when">${fmtDate(c.date)}</span>
354
+ <span class="mono dim">@${c.address}</span>
373
355
  </div>`
374
356
  }
375
- const selectedIdx = entries.findIndex(e => e.sigAddress === currentSigAddr)
376
- const selected = selectedIdx >= 0 ? entries[selectedIdx] : entries[0]
357
+ const selectedIdx = entries.findIndex(e => e.address === currentAddr)
358
+ const isDetached = selectedIdx < 0
359
+ const detachedSummary = (() => {
360
+ let codec = ''
361
+ try { codec = repo.footerToCodec[repo.resolve(currentAddr).at(-1)]?.type || '' } catch {}
362
+ return h`
363
+ <div class="row signed-commit detached-card" data-key="detached">
364
+ <span class="kind">detached</span>
365
+ <span class="msg dim">exploring raw memory${codec ? ` · ${codec}` : ''}</span>
366
+ <span class="when"></span>
367
+ <span class="mono dim">@${currentAddr}</span>
368
+ </div>`
369
+ })()
370
+ const summary = isDetached
371
+ ? detachedSummary
372
+ : commitRow(entries[selectedIdx], tagFor(selectedIdx), { asSummary: true })
377
373
  return h`
378
374
  <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}
375
+ <summary>${summary}</summary>
376
+ <div class="dropdown-body">
377
+ ${entries.map((e, i) => commitRow(e, tagFor(i), { isSelected: !isDetached && i === selectedIdx }))}
378
+ </div>
385
379
  </details>
386
380
  `
387
381
  }
388
382
 
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.
383
+ // Repo-wide "other storage chunks" list — Duples, raw OBJECTs, ARRAYs,
384
+ // STRINGs, etc. The chunks underneath the commit graph. Tucked into a
385
+ // closed <details> so it doesn't compete with primary content. Unsigned
386
+ // commits already appear in the selector dropdown (with a pending badge),
387
+ // so they don't need a second listing here.
392
388
  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')
389
+ const others = [...commitsNewestFirst(repo)].filter(e => e.kind === 'other')
390
+ if (!others.length) return null
396
391
  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}
392
+ <details class="other-storage">
393
+ <summary>storage chunks <span class="dim">(${others.length})the chunks underneath</span></summary>
394
+ <table class="kv clickable">
395
+ <tbody>
396
+ ${others.map(e => h`
397
+ <tr data-key=${`o${e.address}`} data-action="open-at"
398
+ data-keyhex=${keyHex} data-addr=${e.address}>
399
+ <td class="mono dim">${e.codecType}</td>
400
+ <td>${(() => { try { return typedValue(repo.decode(e.address)) } catch { return '' } })()}</td>
401
+ <td class="mono dim">@${e.address}</td>
402
+ </tr>
403
+ `)}
404
+ </tbody>
405
+ </table>
406
+ </details>
426
407
  `
427
408
  }
428
409
 
@@ -439,15 +420,15 @@ function AtView ({ keyHex, address }) {
439
420
  if (!repo) return h`<div class="empty">opening…</div>`
440
421
 
441
422
  // 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.
423
+ // repo has no commits yet, render a useful "no HEAD" page that
424
+ // still surfaces any storage chunks.
444
425
  let resolvedAddr = address
445
426
  if (address === 'HEAD') {
446
427
  resolvedAddr = resolveHead(repo)
447
428
  if (resolvedAddr === undefined) {
448
429
  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>
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>
451
432
  ${repoExtras(repo, keyHex)}
452
433
  `
453
434
  }
@@ -462,8 +443,11 @@ function AtView ({ keyHex, address }) {
462
443
  const isCommit = isCommitShape(decoded)
463
444
  const isSig = codecType === 'SIGNATURE'
464
445
 
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.
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".
467
451
  const tabs = h`
468
452
  <nav class="tabs">
469
453
  <a class=${() => { dep(); return ['tab', atTab === 'value' ? 'active' : null] }}
@@ -472,7 +456,7 @@ function AtView ({ keyHex, address }) {
472
456
  data-action="set-tab" data-tab="storage">storage</a>
473
457
  </nav>
474
458
  `
475
- const selector = isSig ? commitSelectorSection(repo, keyHex, resolvedAddr) : null
459
+ const selector = commitSelectorSection(repo, keyHex, resolvedAddr)
476
460
 
477
461
  // Storage tab: spatial view of where this chunk lives in the byte
478
462
  // stream + outgoing references + this chunk's bytes + incoming
@@ -488,37 +472,84 @@ function AtView ({ keyHex, address }) {
488
472
  `
489
473
  }
490
474
 
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
+
491
479
  // Value tab — branches by codec.
480
+ // Helper: render the kv-table of decoded fields for any Object/Array
481
+ // (including commits, which are just OBJECTs with a known shape).
482
+ // Inline children render their value directly; addressable children
483
+ // get a clickable @addr link in the third column.
484
+ const refsTable = () => {
485
+ if (!refs || typeof refs !== 'object') return null
486
+ const isArray = Array.isArray(refs)
487
+ const fieldEntries = isArray
488
+ ? refs.map((addr, i) => [String(i), addr])
489
+ : Object.entries(refs)
490
+ if (fieldEntries.length === 0) return h`<div class="empty">${isArray ? '[]' : '{}'}</div>`
491
+ return h`
492
+ <table class="kv clickable">
493
+ <tbody>
494
+ ${fieldEntries.map(([k, childAddr]) => {
495
+ if (childAddr === undefined) {
496
+ const inlineValue = isArray ? decoded[+k] : decoded[k]
497
+ return h`
498
+ <tr>
499
+ <td class="mono">${k}</td>
500
+ <td>${typedValue(inlineValue)}</td>
501
+ <td class="dim">(inline)</td>
502
+ </tr>
503
+ `
504
+ }
505
+ let preview = ''
506
+ try { preview = typedValue(repo.decode(childAddr)) }
507
+ catch { preview = '(error)' }
508
+ return h`
509
+ <tr data-key=${k} data-action="open-at"
510
+ data-keyhex=${keyHex} data-addr=${childAddr}>
511
+ <td class="mono">${k}</td>
512
+ <td>${preview}</td>
513
+ <td class="mono dim">@${childAddr}</td>
514
+ </tr>
515
+ `
516
+ })}
517
+ </tbody>
518
+ </table>
519
+ `
520
+ }
521
+
522
+ // Commit: same direct kv-table format as Object (it *is* an OBJECT —
523
+ // user requested the "dumber" version that names every field rather
524
+ // than packing them into a polished headline). Banner shows the
525
+ // verify state from the covering sig; the verification table at the
526
+ // bottom links to that sig and shows its bytes.
492
527
  if (isCommit) {
528
+ const covering = findCoveringSig(repo, resolvedAddr)
493
529
  const parentDataAddr = decoded.parent !== undefined
494
530
  ? safeGet(() => repo.decode(decoded.parent)?.dataAddress)
495
531
  : undefined
496
532
  const changes = parentDataAddr !== undefined
497
533
  ? [...changedPaths(repo, parentDataAddr, decoded.dataAddress)]
498
534
  : null
535
+ const banner = kindBanner(
536
+ covering ? 'signed commit' : 'commit (unsigned)',
537
+ covering
538
+ ? () => {
539
+ dep()
540
+ const status = verifyStatus(repo, keyHex, covering.decoded, covering.sigAddress)
541
+ return h`${verifyBadge(status)} <span class="dim">${verifyLabel(status)}</span>`
542
+ }
543
+ : h`<span class="verify-badge pending">…</span><span class="dim">not yet signed — sign in flight or pending</span>`,
544
+ covering ? 'verified' : 'unsigned'
545
+ )
499
546
  return h`
547
+ ${selector}
500
548
  ${tabs}
501
- <div class="dim">codec: ${codecType} · this is a commit</div>
502
- <table class="kv">
503
- <tbody>
504
- <tr><td>message</td><td>${decoded.message || h`<span class="dim">(empty)</span>`}</td></tr>
505
- <tr><td>date</td><td>${fmtDate(decoded.date)}</td></tr>
506
- <tr>
507
- <td>dataAddress</td>
508
- <td><a class="addr-link" data-action="open-at"
509
- data-keyhex=${keyHex} data-addr=${decoded.dataAddress}
510
- >@${decoded.dataAddress}</a></td>
511
- </tr>
512
- <tr>
513
- <td>parent</td>
514
- <td>${decoded.parent === undefined
515
- ? h`<span class="dim">(none — first commit)</span>`
516
- : h`<a class="addr-link" data-action="open-at"
517
- data-keyhex=${keyHex} data-addr=${decoded.parent}
518
- >@${decoded.parent}</a>`}</td>
519
- </tr>
520
- </tbody>
521
- </table>
549
+ ${banner}
550
+ ${refsTable()}
551
+ <h3>rehydrated</h3>
552
+ <pre class="value">${safeJSON(decoded)}</pre>
522
553
  ${changes
523
554
  ? h`
524
555
  <h3>changed paths <span class="dim">(${changes.length})</span></h3>
@@ -527,16 +558,32 @@ function AtView ({ keyHex, address }) {
527
558
  : h`<div class="dim">(no path-level changes — same dataAddress)</div>`}
528
559
  `
529
560
  : null}
530
- <h3>rehydrated</h3>
531
- <pre class="value">${safeJSON(decoded)}</pre>
561
+ ${covering ? h`
562
+ <h3>verification</h3>
563
+ <table class="kv">
564
+ <tbody>
565
+ <tr>
566
+ <td>signature</td>
567
+ <td><a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${covering.sigAddress}>@${covering.sigAddress}</a></td>
568
+ </tr>
569
+ <tr>
570
+ <td>covers</td>
571
+ <td>@${covering.signedFrom} through @${covering.signedTo} (${covering.signedTo - covering.signedFrom + 1} bytes)</td>
572
+ </tr>
573
+ <tr><td>sig bytes</td><td class="mono">${truncHex(covering.decoded.compactRawBytes, 32)}</td></tr>
574
+ </tbody>
575
+ </table>
576
+ ` : null}
577
+ ${repoExtras(repo, keyHex)}
532
578
  `
533
579
  }
534
580
 
535
581
  // Duple: explain what this tree-node IS, then show its two children.
536
582
  if (codecType === 'DUPLE') {
537
583
  return h`
584
+ ${selector}
538
585
  ${tabs}
539
- <div class="dim">codec: DUPLE</div>
586
+ ${kindBanner('duple', h`<span class="dim">2-tuple, tree scaffolding</span>`)}
540
587
  <p class="explainer">
541
588
  A <strong>Duple</strong> is a 2-tuple — the building block streamo uses
542
589
  to balance binary trees of OBJECT entries and ARRAY elements. Each Duple
@@ -548,21 +595,31 @@ function AtView ({ keyHex, address }) {
548
595
  </p>
549
596
  <table class="kv">
550
597
  <tbody>
551
- <tr><td class="mono">v[0]</td><td>${previewValue(decoded.v[0])}</td></tr>
552
- <tr><td class="mono">v[1]</td><td>${previewValue(decoded.v[1])}</td></tr>
598
+ <tr><td class="mono">v[0]</td><td>${typedValue(decoded.v[0])}</td></tr>
599
+ <tr><td class="mono">v[1]</td><td>${typedValue(decoded.v[1])}</td></tr>
553
600
  </tbody>
554
601
  </table>
555
602
  `
556
603
  }
557
604
 
558
- // Signature: the polished "signed commit" view + the unsigned/storage
559
- // listing below. Shared with the storage tab's chunk-detail view.
605
+ // Signature: the sig-detail page (auxiliary in the new model — sigs
606
+ // are how commits are verified, not the user-level unit). Lists
607
+ // the commits this sig covers; pick one to land on its commit page.
560
608
  if (isSig) {
609
+ const banner = kindBanner(
610
+ 'signature chunk',
611
+ () => {
612
+ dep()
613
+ const status = verifyStatus(repo, keyHex, decoded, resolvedAddr)
614
+ return h`${verifyBadge(status)} <span class="dim">${verifyLabel(status)}</span>`
615
+ },
616
+ 'verified'
617
+ )
561
618
  return h`
562
619
  ${selector}
563
620
  ${tabs}
564
- ${signedCommitDetail(repo, keyHex, resolvedAddr)}
565
- ${repoExtras(repo, keyHex)}
621
+ ${banner}
622
+ ${sigDetailBody(repo, keyHex, resolvedAddr, decoded)}
566
623
  <div class="dim" style="margin-top: 0.5rem;">switch to the <strong>storage</strong> tab above to see the raw chunk bytes, outgoing references, and what else points at this address.</div>
567
624
  `
568
625
  }
@@ -570,59 +627,30 @@ function AtView ({ keyHex, address }) {
570
627
  // Object/array: clickable children with their addresses.
571
628
  if (refs && typeof refs === 'object') {
572
629
  const isArray = Array.isArray(refs)
573
- const entries = isArray
574
- ? refs.map((addr, i) => [String(i), addr])
575
- : Object.entries(refs)
576
- if (entries.length === 0) {
577
- return h`
578
- ${tabs}
579
- <div class="dim">codec: ${codecType}</div>
580
- <div class="empty">${isArray ? '[]' : '{}'}</div>
581
- `
582
- }
630
+ const fieldCount = isArray ? refs.length : Object.keys(refs).length
631
+ const dim = fieldCount === 0
632
+ ? null
633
+ : h`<span class="dim">${isArray ? `length ${fieldCount}` : `${fieldCount} field${fieldCount === 1 ? '' : 's'}`}</span>`
634
+ const label = fieldCount === 0
635
+ ? (isArray ? 'empty array' : 'empty object')
636
+ : (isArray ? 'array' : 'object')
583
637
  return h`
638
+ ${selector}
584
639
  ${tabs}
585
- <div class="dim">codec: ${codecType}${isArray ? ` · length ${entries.length}` : ''}</div>
586
- <table class="kv clickable">
587
- <tbody>
588
- ${entries.map(([k, childAddr]) => {
589
- // asRefs is mutation-impossible, so it returns undefined for
590
- // inline children that don't have a separate chunk address.
591
- // Show those non-clickably with the decoded value pulled from
592
- // the parent.
593
- if (childAddr === undefined) {
594
- const inlineValue = isArray ? decoded[+k] : decoded[k]
595
- return h`
596
- <tr>
597
- <td class="mono">${k}</td>
598
- <td>${previewValue(inlineValue)}</td>
599
- <td class="dim">(inline)</td>
600
- </tr>
601
- `
602
- }
603
- let preview = ''
604
- try { preview = previewValue(repo.decode(childAddr)) }
605
- catch { preview = '(error)' }
606
- return h`
607
- <tr data-key=${k} data-action="open-at"
608
- data-keyhex=${keyHex} data-addr=${childAddr}>
609
- <td class="mono">${k}</td>
610
- <td>${preview}</td>
611
- <td class="mono dim">@${childAddr}</td>
612
- </tr>
613
- `
614
- })}
615
- </tbody>
616
- </table>
617
- <h3>rehydrated</h3>
618
- <pre class="value">${safeJSON(decoded)}</pre>
640
+ ${kindBanner(label, dim)}
641
+ ${refsTable()}
642
+ ${fieldCount > 0 ? h`
643
+ <h3>rehydrated</h3>
644
+ <pre class="value">${safeJSON(decoded)}</pre>
645
+ ` : null}
619
646
  `
620
647
  }
621
648
 
622
649
  // Primitive: just show it.
623
650
  return h`
651
+ ${selector}
624
652
  ${tabs}
625
- <div class="dim">codec: ${codecType}</div>
653
+ ${kindBanner(codecType.toLowerCase())}
626
654
  <pre class="value">${safeJSON(decoded)}</pre>
627
655
  `
628
656
  }}
@@ -764,7 +792,7 @@ function outgoingReferencesSection (repo, keyHex, address) {
764
792
  try {
765
793
  const childCode = repo.resolve(childAddr)
766
794
  codecType = repo.footerToCodec[childCode.at(-1)]?.type || '?'
767
- preview = previewValue(repo.decode(childAddr))
795
+ preview = typedValue(repo.decode(childAddr))
768
796
  } catch { preview = '(error)' }
769
797
  return h`
770
798
  <tr data-key=${`out${i}@${childAddr}`} data-action="open-at"
@@ -802,7 +830,7 @@ function referrersSection (repo, keyHex, address) {
802
830
  <tbody>
803
831
  ${refs.map(r => {
804
832
  let preview = ''
805
- try { preview = previewValue(repo.decode(r.address)) }
833
+ try { preview = typedValue(repo.decode(r.address)) }
806
834
  catch { preview = '(error)' }
807
835
  return h`
808
836
  <tr data-key=${`r${r.address}`} data-action="open-at"
@@ -825,19 +853,44 @@ function isDuple (v) {
825
853
  return v && typeof v === 'object' && Array.isArray(v.v) && v.v.length === 2 && Object.keys(v).length === 1
826
854
  }
827
855
 
828
- function previewValue (v, depth = 0) {
829
- if (v == null) return String(v)
830
- if (typeof v === 'string') return v.length > 60 ? JSON.stringify(v.slice(0, 60)) + '…' : JSON.stringify(v)
831
- if (typeof v === 'number' || typeof v === 'boolean') return String(v)
832
- if (v instanceof Date) return v.toISOString()
833
- if (v instanceof Uint8Array) return `Uint8Array(${v.length})`
856
+ // Streamo-typed value renderer every value gets a visual identity
857
+ // matching its underlying codec, instead of being flattened through
858
+ // JSON.stringify. Primitives render with type-specific styling
859
+ // (string quoted mono in green frame, date <time> with calendar
860
+ // chip, number number chip, etc.); composites currently render as
861
+ // count chips ({ N fields } / [ N elements ]) depth-controlled
862
+ // expansion is the next step in this thread (see THREADS.md).
863
+ 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>`
866
+ if (typeof v === 'boolean') {
867
+ return h`<span class=${['tv', 'tv-bool', v ? 'tv-true' : 'tv-false']}>${v ? '✓' : '✗'} ${String(v)}</span>`
868
+ }
869
+ if (typeof v === 'string') {
870
+ 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>`
872
+ }
873
+ if (typeof v === 'number') {
874
+ return h`<span class="tv tv-num">${String(v)}</span>`
875
+ }
876
+ 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>`
878
+ }
879
+ if (v instanceof Uint8Array) {
880
+ return h`<span class="tv tv-bytes">Uint8Array(${v.length})</span>`
881
+ }
834
882
  if (isDuple(v)) {
835
- if (depth > 2) return 'Duple(…)'
836
- return `Duple(${previewValue(v.v[0], depth + 1)}, ${previewValue(v.v[1], depth + 1)})`
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>`
885
+ }
886
+ if (Array.isArray(v)) {
887
+ return h`<span class="tv tv-array">[ ${v.length} ${v.length === 1 ? 'element' : 'elements'} ]</span>`
837
888
  }
838
- if (Array.isArray(v)) return `[…] (${v.length})`
839
- if (typeof v === 'object') return `{…} (${Object.keys(v).length})`
840
- return String(v)
889
+ if (typeof v === 'object') {
890
+ const n = Object.keys(v).length
891
+ return h`<span class="tv tv-object">{ ${n} ${n === 1 ? 'field' : 'fields'} }</span>`
892
+ }
893
+ return h`<span class="tv">${String(v)}</span>`
841
894
  }
842
895
 
843
896
  function safeGet (f) { try { return f() } catch { return undefined } }
@@ -925,6 +978,28 @@ function verifyStatus (repo, keyHex, sig, sigAddress) {
925
978
  return 'pending'
926
979
  }
927
980
 
981
+ // Consistent "what this is" banner at the top of every value-tab branch.
982
+ // label is the short codec/role name (e.g. "signed commit", "object",
983
+ // "duple"); content is whatever else goes in the banner (verify badge +
984
+ // label, field count, etc.); variant tints the surface — 'verified' for
985
+ // commits/sigs with a covering signature, 'unsigned' for commits awaiting
986
+ // one, undefined for everything else.
987
+ function kindBanner (label, content, variant) {
988
+ return h`
989
+ <div class=${['kind-banner', variant || null]}>
990
+ <span class="kind-label">${label}</span>
991
+ ${content || null}
992
+ </div>
993
+ `
994
+ }
995
+
996
+ function verifyLabel (status) {
997
+ if (status === 'valid') return 'verified — bytes match this repo’s public key'
998
+ if (status === 'invalid') return 'NOT VERIFIED — bytes do not match the repo key'
999
+ if (status === 'pending') return 'verifying…'
1000
+ return `error: ${status?.error ?? 'unknown'}`
1001
+ }
1002
+
928
1003
  function verifyBadge (status) {
929
1004
  if (status === 'valid') return h`<span class="verify-badge valid" title="signature verified against repo's public key">✓</span>`
930
1005
  if (status === 'invalid') return h`<span class="verify-badge invalid" title="signature does NOT match repo's public key">✗</span>`
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @file bridgeRegistry — connect a multi-repo registry to an app Recaller.
3
+ *
4
+ * Each Repo has its own Recaller so it can track fine-grained dependencies
5
+ * on its own internal keys. An app that uses many Repos has its own
6
+ * different Recaller for its mount() slots. Reading repo.byteLength inside
7
+ * a slot registers a dep on the *repo's* recaller, not the app's, so
8
+ * without an explicit bridge the slot would never re-run when chunks
9
+ * arrive at the repo.
10
+ *
11
+ * bridgeRegistry sets up that bridge once: it watches every repo in the
12
+ * registry (existing and future) for chunk arrivals and forwards them as
13
+ * a single signal on the app recaller. The returned `dep` function is
14
+ * what slots call to register on that signal — call it inside any
15
+ * reactive cell that should re-run on any-repo-changed.
16
+ *
17
+ * const recaller = new Recaller('app')
18
+ * const { dep, fire } = bridgeRegistry(registry, recaller)
19
+ *
20
+ * mount(h`${() => {
21
+ * dep()
22
+ * for (const [k, r] of registry) ... // freely read any repo's state
23
+ * }}`, appEl, recaller)
24
+ *
25
+ * // Non-repo state changes (route, async results, etc.) — call fire()
26
+ * // to force a re-render; the slot re-runs at next tick.
27
+ * window.addEventListener('hashchange', fire)
28
+ *
29
+ * Mutation is synchronous so multiple mutations in a tick coalesce via the
30
+ * Recaller's own nextTick flush — one slot re-run per tick regardless of
31
+ * how many chunks arrive. Don't wrap fire() in requestAnimationFrame:
32
+ * when the tab loses focus, queued rAFs throttle and the display freezes
33
+ * (we learned this the hard way; see the design.md cross-recaller note).
34
+ *
35
+ * @param {import('./RepoRegistry.js').RepoRegistry} registry
36
+ * @param {import('./utils/Recaller.js').Recaller} recaller
37
+ * @param {string} [name='bridge'] used in watch names for debugging
38
+ * @returns {{dep: () => void, fire: () => void}}
39
+ * `dep()` registers the calling reactive cell as depending on bridge state.
40
+ * `fire()` forces the slot to re-run at next tick — useful for app-level
41
+ * state changes (route, async results, tab switches) that aren't repo
42
+ * mutations the bridge already forwards.
43
+ */
44
+ export function bridgeRegistry (registry, recaller, name = 'bridge') {
45
+ const signal = {}
46
+ const watched = new Set()
47
+
48
+ const fire = () => recaller.reportKeyMutation(signal, 'data')
49
+ const dep = () => recaller.reportKeyAccess(signal, 'data')
50
+
51
+ function watchRepo (keyHex, repo) {
52
+ if (watched.has(keyHex)) return
53
+ watched.add(keyHex)
54
+ repo.watch(`${name}:${keyHex}`, () => {
55
+ repo.byteLength // register 'length' dep — fires on every chunk
56
+ fire()
57
+ })
58
+ }
59
+
60
+ for (const [k, r] of registry) watchRepo(k, r)
61
+ registry.onOpen((k, r) => { watchRepo(k, r); fire() })
62
+
63
+ return { dep, fire }
64
+ }
@@ -126,7 +126,7 @@ rl.on('line', async line => {
126
126
  const messages = myRepo.get('messages') ?? []
127
127
  const preview = text.length > 50 ? text.slice(0, 50).trim() + '…' : text
128
128
  myRepo.defaultMessage = `"${preview}" (cli)`
129
- myRepo.set({ name: username, messages: [...messages, { text, at: Date.now() }] })
129
+ myRepo.set({ name: username, messages: [...messages, { text, at: new Date() }] })
130
130
  rl.prompt()
131
131
  })
132
132
 
@@ -192,11 +192,30 @@ function reconcileSlot (start, end, newVNodes, recaller, ns = HTML_NS) {
192
192
  old.remove()
193
193
  }
194
194
 
195
- // Reinsert recycled elements (static attrs patched) and mount fresh ones, in order
195
+ // Reinsert recycled elements and mount fresh ones, in order. For
196
+ // recycled elements we re-mount: clean up the existing subtree's
197
+ // watchers, clear inner DOM and any attributes set on the outer
198
+ // element, then apply fresh attrs and mount fresh children. The
199
+ // OUTER node identity is preserved (so document position and DOM
200
+ // references survive), but inner state (focus, scroll, slot
201
+ // anchors) is rebuilt — a static `${value}` child captured at
202
+ // first mount would otherwise be stale on every re-render.
203
+ // Inputs that need focus preservation across re-renders should
204
+ // be kept in their own data-keyed slot so reconcileSlot's matcher
205
+ // recycles them in place separately from this outer rebuild.
196
206
  for (const vnode of newVNodes) {
197
207
  const recycled = vnodeToEl.get(vnode)
198
208
  if (recycled) {
199
- patchElement(recycled, vnode)
209
+ cleanupNode(recycled, recaller)
210
+ while (recycled.firstChild) recycled.firstChild.remove()
211
+ // Clear all attributes (snapshot the names — removeAttribute mutates the live list)
212
+ const oldAttrNames = Array.from(recycled.attributes, a => a.name)
213
+ for (const name of oldAttrNames) recycled.removeAttribute(name)
214
+ for (const attr of vnode.attrs) {
215
+ if (attr == null) continue
216
+ applyAttr(recycled, attr, recaller)
217
+ }
218
+ mount(vnode.children, recycled, recaller, ns)
200
219
  end.before(recycled)
201
220
  } else {
202
221
  const frag = document.createDocumentFragment()
@@ -206,18 +225,6 @@ function reconcileSlot (start, end, newVNodes, recaller, ns = HTML_NS) {
206
225
  }
207
226
  }
208
227
 
209
- // Update static attributes on a recycled element.
210
- // Reactive (function/array) attrs are already self-updating via their existing watchers.
211
- function patchElement (el, vnode) {
212
- for (const attr of vnode.attrs) {
213
- if (attr == null) continue
214
- if (typeof attr === 'object' && !attr.name) continue // spread — skip
215
- if (typeof attr.value === 'function' || Array.isArray(attr.value)) continue // reactive — skip
216
- if (attr.value !== undefined) setAttr(el, attr.name, attr.value)
217
- else el.toggleAttribute(attr.name, true)
218
- }
219
- }
220
-
221
228
  // ── Function components ───────────────────────────────────────────────────
222
229
  //
223
230
  // When an HElement's tag is a function, call it with a props object instead