@dtudury/streamo 4.0.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtudury/streamo",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
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",
@@ -13,8 +13,10 @@ function fmt (ts) {
13
13
  }
14
14
 
15
15
  function Msg ({ name, text, at, mine }) {
16
+ // +at coerces both Date and number to ms — stable key across old (number)
17
+ // and new (Date) message records as we transition.
16
18
  return h`
17
- <div class=${['msg', mine ? 'mine' : 'theirs']} data-key=${at}>
19
+ <div class=${['msg', mine ? 'mine' : 'theirs']} data-key=${+at}>
18
20
  ${!mine ? h`<div class="sender">${name}</div>` : null}
19
21
  <div class="text">${text}</div>
20
22
  <div class="time">${fmt(at)}</div>
@@ -129,7 +131,7 @@ joinBtn.onclick = async () => {
129
131
  const messages = myRepo.get('messages') ?? []
130
132
  const preview = text.length > 50 ? text.slice(0, 50).trim() + '…' : text
131
133
  myRepo.defaultMessage = `"${preview}" (web)`
132
- myRepo.set({ name: username, messages: [...messages, { text, at: Date.now() }] })
134
+ myRepo.set({ name: username, messages: [...messages, { text, at: new Date() }] })
133
135
  }
134
136
 
135
137
  sendBtn.onclick = sendMessage
@@ -51,10 +51,10 @@
51
51
  text-align: center;
52
52
  align-self: center;
53
53
  }
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); }
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.signed-commit.unsigned .kind { color: var(--ink-dim); border-color: var(--ink-dim); }
58
58
 
59
59
  /* HEAD card — the most-recent signed commit, prominent and self-orienting. */
60
60
  .row.signed-commit.head-card {
@@ -64,6 +64,22 @@
64
64
  }
65
65
  .row.signed-commit.head-card .msg { font-size: 1rem; font-weight: 500; }
66
66
 
67
+ /* Detached card — same layout as the head-card but neutral styling.
68
+ Shown as the selector summary when the current address isn't a sig
69
+ (you've drilled into raw memory). The dropdown body is still the
70
+ way back — pick a real commit and you re-attach. */
71
+ .row.signed-commit.detached-card {
72
+ border: 1.5px dashed var(--rule);
73
+ background: transparent;
74
+ padding: 0.85rem;
75
+ cursor: pointer;
76
+ }
77
+ .row.signed-commit.detached-card .kind {
78
+ color: var(--ink-dim);
79
+ border-color: var(--ink-dim);
80
+ }
81
+ .row.signed-commit.detached-card .msg { font-size: 0.95rem; }
82
+
67
83
  /* Commit selector: a real dropdown widget. Summary = currently-selected
68
84
  commit (HEAD by default), styled as the green head-card. Body =
69
85
  full list of signed commits, with the selected one marked. */
@@ -113,17 +129,25 @@
113
129
  }
114
130
  details.other-storage[open] > summary { color: var(--ink); }
115
131
 
116
- /* Polished signed-commit detail view (AtView SIGNATURE branch). */
117
- .signed-commit-banner {
132
+ /* "What this is" banner top of every value tab. Default neutral
133
+ border for storage codecs; green .verified for commits or sigs
134
+ backed by a valid signature; dim .unsigned for commits awaiting
135
+ a signature. */
136
+ .kind-banner {
118
137
  display: flex; align-items: center; gap: 0.5rem;
119
138
  padding: 0.65rem 0.85rem; margin: 0.5rem 0 1rem;
120
- border: 1.5px solid #16a34a; border-radius: var(--radius);
139
+ border: 1.5px solid var(--rule); border-radius: var(--radius);
140
+ }
141
+ .kind-banner.verified {
142
+ border-color: #16a34a;
121
143
  background: rgba(22, 163, 74, 0.06);
122
144
  }
123
- .signed-commit-banner .signed-label {
145
+ .kind-banner.unsigned { border-style: dashed; }
146
+ .kind-banner .kind-label {
124
147
  font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em;
125
- font-weight: 600; color: #16a34a;
148
+ font-weight: 600; color: var(--ink-dim);
126
149
  }
150
+ .kind-banner.verified .kind-label { color: #16a34a; }
127
151
  .commit-card {
128
152
  padding: 0.6rem 0.85rem; margin: 0.4rem 0;
129
153
  border: 1px solid var(--rule); border-radius: var(--radius);
@@ -134,58 +134,49 @@ function safeJSON (value) {
134
134
  }, 2)
135
135
  }
136
136
 
137
- // Walk every chunk newest-to-oldest, bundling each signature with the
138
- // commit(s) it covers into one "signed commit" entry. A signed commit is
139
- // the natural unit of authored history the sig is what makes the bytes
140
- // provably yours, and the commit gives those bytes meaning, so they read
141
- // as one thing. Concurrent commits batched into one sign produce a single
142
- // entry whose .commits is multi-element.
143
- //
144
- // Commits more recent than the latest sig (sign in flight, or none yet)
145
- // surface as 'unsignedCommit'. Everything elseDuples, OBJECTs that
146
- // aren't commits, ARRAYs, STRINGs, etc. — is 'other' and lives in the
147
- // storage section.
148
- function * signedCommits (repo) {
137
+ // Walk every chunk newest-first, yielding one entry per commit (with
138
+ // its covering signature attached) and one 'other' entry per non-commit
139
+ // non-sig chunk. A signature is part of *how* a commit is verified, not
140
+ // a thing of its own so the user-level unit is the commit. Walking
141
+ // newest-first, we encounter each sig before the commits it covers
142
+ // (sig has higher address than the bytes it signed); we track the
143
+ // most-recently-seen sig and attach it to subsequent commits as their
144
+ // 'covering'. Commits encountered before any sig are uncovered (sign
145
+ // in flight or none yet)those have covering: null.
146
+ function * commitsNewestFirst (repo) {
149
147
  const len = repo.byteLength
150
148
  if (len <= 0) return
151
149
  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
-
150
+ let covering = null // most-recent sig encountered in this walk
160
151
  while (addr >= 0) {
161
152
  const code = repo.resolve(addr)
162
153
  if (!code || !code.length) break
163
154
  const type = repo.footerToCodec[code.at(-1)]?.type
164
-
165
155
  if (type === 'SIGNATURE') {
166
- for (const e of flush()) yield e
167
156
  let sig
168
157
  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 = []
158
+ if (sig) {
159
+ covering = {
160
+ sigAddress: addr,
161
+ signedFrom: sig.address,
162
+ signedTo: addr - code.length,
163
+ sigHex: truncHex(sig.compactRawBytes, 12)
164
+ }
165
+ }
166
+ yield { kind: 'sig', address: addr, codecType: type }
178
167
  } else if (type === 'OBJECT') {
179
168
  let value
180
169
  try { value = repo.decode(addr) } catch { value = null }
181
170
  if (isCommitShape(value)) {
182
- pendingCommits.push({
171
+ yield {
172
+ kind: 'commit',
183
173
  address: addr,
184
174
  message: value.message,
185
175
  date: value.date,
186
176
  dataAddress: value.dataAddress,
187
- parent: value.parent
188
- })
177
+ parent: value.parent,
178
+ covering
179
+ }
189
180
  } else {
190
181
  yield { kind: 'other', address: addr, codecType: type }
191
182
  }
@@ -194,67 +185,47 @@ function * signedCommits (repo) {
194
185
  }
195
186
  addr -= code.length
196
187
  }
197
- for (const e of flush()) yield e
198
188
  }
199
189
 
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>` }
190
+ // Find the covering signature for a commit — the first signature chunk
191
+ // newer than the commit whose [signedFrom, signedTo] range includes its
192
+ // address. Returns { sigAddress, signedFrom, signedTo, decoded } or null
193
+ // if the commit is uncovered (sign in flight or pending).
194
+ function findCoveringSig (repo, commitAddr) {
195
+ let scan = repo.byteLength - 1
196
+ while (scan > commitAddr) {
197
+ const code = repo.resolve(scan)
198
+ if (!code || !code.length) break
199
+ if (repo.footerToCodec[code.at(-1)]?.type === 'SIGNATURE') {
200
+ let sig
201
+ try { sig = repo.decode(scan) } catch { sig = null }
202
+ if (sig && sig.address <= commitAddr && (scan - code.length) >= commitAddr) {
203
+ return { sigAddress: scan, signedFrom: sig.address, signedTo: scan - code.length, decoded: sig }
204
+ }
205
+ }
206
+ scan -= code.length
207
+ }
208
+ return null
209
+ }
210
+
211
+ // Sig-detail view — when you're at a sig chunk directly (e.g., from
212
+ // drilling through storage). Sigs are auxiliary in the new model — the
213
+ // user-level unit is the commit — so this page shows the sig's content
214
+ // without trying to be the "polished signed commit" page. The kindBanner
215
+ // is rendered by the caller (AtView) so its variant matches the rest of
216
+ // the value-tab branches.
217
+ function sigDetailBody (repo, keyHex, sigAddress, decoded) {
208
218
  const chunk = repo.resolve(sigAddress)
209
219
  const chunkLen = chunk.length
210
220
  const signedTo = sigAddress - chunkLen
211
221
  const sigChunkStart = sigAddress - chunkLen + 1
212
222
  const covered = commitsCoveredBySignature(repo, decoded.address, signedTo)
213
- const head = covered[0]
214
223
  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
224
  <table class="kv">
252
225
  <tbody>
253
226
  <tr>
254
227
  <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>
228
+ <td>@${decoded.address} through @${signedTo} (${signedTo - decoded.address + 1} bytes)</td>
258
229
  </tr>
259
230
  <tr>
260
231
  <td>sig chunk</td>
@@ -263,6 +234,19 @@ function signedCommitDetail (repo, keyHex, sigAddress) {
263
234
  <tr><td>bytes</td><td class="mono">${truncHex(decoded.compactRawBytes, 32)}</td></tr>
264
235
  </tbody>
265
236
  </table>
237
+ ${covered.length ? h`
238
+ <h3>commits in this signature ${covered.length > 1 ? h`<span class="dim">(${covered.length}, batched in one sign)</span>` : null}</h3>
239
+ ${covered.map(c => h`
240
+ <div class="commit-card" data-key=${`cc${c.address}`} data-action="open-at"
241
+ data-keyhex=${keyHex} data-addr=${c.address}>
242
+ <div class="commit-msg">${c.message || h`<span class="dim">(no message)</span>`}</div>
243
+ <div class="commit-meta dim">
244
+ <span>${fmtDate(c.date)}</span>
245
+ <span> · @${c.address}</span>
246
+ </div>
247
+ </div>
248
+ `)}
249
+ ` : null}
266
250
  `
267
251
  }
268
252
 
@@ -328,101 +312,108 @@ function RegistryView () {
328
312
  `
329
313
  }
330
314
 
331
- // Resolve the symbolic HEAD address to the most-recent sig chunk's
332
- // address. Returns undefined if the repo has no signatures yet.
315
+ // Resolve the symbolic HEAD address to the most-recent COMMIT chunk's
316
+ // address not the most-recent signature. The user-level unit is the
317
+ // commit; sigs are how it's verified, but HEAD-as-a-commit is what
318
+ // people mean by "the latest." Returns undefined if there are no commits.
333
319
  function resolveHead (repo) {
334
320
  let walk = repo.byteLength - 1
335
321
  while (walk >= 0) {
336
322
  const code = repo.resolve(walk)
337
323
  if (!code || !code.length) break
338
- if (repo.footerToCodec[code.at(-1)]?.type === 'SIGNATURE') return walk
324
+ if (repo.footerToCodec[code.at(-1)]?.type === 'OBJECT') {
325
+ let value
326
+ try { value = repo.decode(walk) } catch { value = null }
327
+ if (isCommitShape(value)) return walk
328
+ }
339
329
  walk -= code.length
340
330
  }
341
331
  return undefined
342
332
  }
343
333
 
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')
334
+ // Commit selector dropdown — always rendered at the top of an at-view
335
+ // when the repo has any commits. The dropdown enumerates COMMITS (not
336
+ // sigs), since the commit is the user-level unit. Each entry's verify
337
+ // badge comes from its covering sig; uncovered commits show a "pending"
338
+ // badge. When the current address is a commit, that row is the summary;
339
+ // otherwise the summary is a "detached" card (you're at a sig chunk, a
340
+ // Duple, raw bytes, etc. drill state, not a named ref).
341
+ function commitSelectorSection (repo, keyHex, currentAddr) {
342
+ const entries = [...commitsNewestFirst(repo)].filter(e => e.kind === 'commit')
351
343
  if (!entries.length) return null
352
344
  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 || ''}`
345
+ const commitRow = (c, tag, { asSummary = false, isSelected = false } = {}) => {
360
346
  const cls = ['row', 'signed-commit',
361
347
  asSummary ? 'head-card' : null,
362
- isSelected ? 'selected' : null]
348
+ isSelected ? 'selected' : null,
349
+ c.covering ? null : 'unsigned']
363
350
  const action = asSummary ? null : 'select-commit'
351
+ const badge = () => {
352
+ dep()
353
+ if (!c.covering) return h`<span class="verify-badge pending" title="not yet signed">…</span>`
354
+ return verifyBadge(verifyStatus(repo, keyHex, c.covering.decoded || repo.decode(c.covering.sigAddress), c.covering.sigAddress))
355
+ }
364
356
  return h`
365
357
  <div class=${cls}
366
- data-key=${`sc${e.sigAddress}`}
358
+ data-key=${`c${c.address}`}
367
359
  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>
360
+ data-keyhex=${keyHex} data-addr=${c.address}>
361
+ <span class="kind">${tag} ${badge}</span>
362
+ <span class="msg">${c.message || h`<span class="dim">(no message)</span>`}</span>
363
+ <span class="when">${fmtDate(c.date)}</span>
364
+ <span class="mono dim">@${c.address}</span>
373
365
  </div>`
374
366
  }
375
- const selectedIdx = entries.findIndex(e => e.sigAddress === currentSigAddr)
376
- const selected = selectedIdx >= 0 ? entries[selectedIdx] : entries[0]
367
+ const selectedIdx = entries.findIndex(e => e.address === currentAddr)
368
+ const isDetached = selectedIdx < 0
369
+ const detachedSummary = (() => {
370
+ let codec = ''
371
+ try { codec = repo.footerToCodec[repo.resolve(currentAddr).at(-1)]?.type || '' } catch {}
372
+ return h`
373
+ <div class="row signed-commit detached-card" data-key="detached">
374
+ <span class="kind">detached</span>
375
+ <span class="msg dim">exploring raw memory${codec ? ` · ${codec}` : ''}</span>
376
+ <span class="when"></span>
377
+ <span class="mono dim">@${currentAddr}</span>
378
+ </div>`
379
+ })()
380
+ const summary = isDetached
381
+ ? detachedSummary
382
+ : commitRow(entries[selectedIdx], tagFor(selectedIdx), { asSummary: true })
377
383
  return h`
378
384
  <details class="commit-selector" data-key=${`selector-${keyHex}`}>
379
- <summary>${signedRow(selected, tagFor(selectedIdx >= 0 ? selectedIdx : 0), { asSummary: true })}</summary>
380
- ${entries.length > 1 ? h`
381
- <div class="dropdown-body">
382
- ${entries.map((e, i) => signedRow(e, tagFor(i), { isSelected: e === selected }))}
383
- </div>
384
- ` : null}
385
+ <summary>${summary}</summary>
386
+ <div class="dropdown-body">
387
+ ${entries.map((e, i) => commitRow(e, tagFor(i), { isSelected: !isDetached && i === selectedIdx }))}
388
+ </div>
385
389
  </details>
386
390
  `
387
391
  }
388
392
 
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.
393
+ // Repo-wide "other storage chunks" list — Duples, raw OBJECTs, ARRAYs,
394
+ // STRINGs, etc. The chunks underneath the commit graph. Tucked into a
395
+ // closed <details> so it doesn't compete with primary content. Unsigned
396
+ // commits already appear in the selector dropdown (with a pending badge),
397
+ // so they don't need a second listing here.
392
398
  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')
399
+ const others = [...commitsNewestFirst(repo)].filter(e => e.kind === 'other')
400
+ if (!others.length) return null
396
401
  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}
402
+ <details class="other-storage">
403
+ <summary>storage chunks <span class="dim">(${others.length})the chunks underneath</span></summary>
404
+ <table class="kv clickable">
405
+ <tbody>
406
+ ${others.map(e => h`
407
+ <tr data-key=${`o${e.address}`} data-action="open-at"
408
+ data-keyhex=${keyHex} data-addr=${e.address}>
409
+ <td class="mono dim">${e.codecType}</td>
410
+ <td>${(() => { try { return previewValue(repo.decode(e.address)) } catch { return '' } })()}</td>
411
+ <td class="mono dim">@${e.address}</td>
412
+ </tr>
413
+ `)}
414
+ </tbody>
415
+ </table>
416
+ </details>
426
417
  `
427
418
  }
428
419
 
@@ -439,15 +430,15 @@ function AtView ({ keyHex, address }) {
439
430
  if (!repo) return h`<div class="empty">opening…</div>`
440
431
 
441
432
  // 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.
433
+ // repo has no commits yet, render a useful "no HEAD" page that
434
+ // still surfaces any storage chunks.
444
435
  let resolvedAddr = address
445
436
  if (address === 'HEAD') {
446
437
  resolvedAddr = resolveHead(repo)
447
438
  if (resolvedAddr === undefined) {
448
439
  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>
440
+ <h2>at HEAD <span class="dim">(no commits yet)</span></h2>
441
+ <div class="empty">this repo doesn't have any commits yet — HEAD will resolve to the most-recent commit once one lands.</div>
451
442
  ${repoExtras(repo, keyHex)}
452
443
  `
453
444
  }
@@ -462,8 +453,11 @@ function AtView ({ keyHex, address }) {
462
453
  const isCommit = isCommitShape(decoded)
463
454
  const isSig = codecType === 'SIGNATURE'
464
455
 
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.
456
+ // Tabs are part of the page content (not the static header) so the
457
+ // commit selector renders ABOVE the tabs. The selector is always
458
+ // present (when the repo has any commits) so the UI doesn't shift
459
+ // as you click between commit pages and storage drilling — when
460
+ // the current address isn't a commit, the summary shows "detached".
467
461
  const tabs = h`
468
462
  <nav class="tabs">
469
463
  <a class=${() => { dep(); return ['tab', atTab === 'value' ? 'active' : null] }}
@@ -472,7 +466,7 @@ function AtView ({ keyHex, address }) {
472
466
  data-action="set-tab" data-tab="storage">storage</a>
473
467
  </nav>
474
468
  `
475
- const selector = isSig ? commitSelectorSection(repo, keyHex, resolvedAddr) : null
469
+ const selector = commitSelectorSection(repo, keyHex, resolvedAddr)
476
470
 
477
471
  // Storage tab: spatial view of where this chunk lives in the byte
478
472
  // stream + outgoing references + this chunk's bytes + incoming
@@ -488,37 +482,84 @@ function AtView ({ keyHex, address }) {
488
482
  `
489
483
  }
490
484
 
485
+ // Every value-tab branch below prepends ${selector}${tabs} so the
486
+ // UI is stable across navigation: the selector is always at the
487
+ // top of the page when the repo has any sigs.
488
+
491
489
  // Value tab — branches by codec.
490
+ // Helper: render the kv-table of decoded fields for any Object/Array
491
+ // (including commits, which are just OBJECTs with a known shape).
492
+ // Inline children render their value directly; addressable children
493
+ // get a clickable @addr link in the third column.
494
+ const refsTable = () => {
495
+ if (!refs || typeof refs !== 'object') return null
496
+ const isArray = Array.isArray(refs)
497
+ const fieldEntries = isArray
498
+ ? refs.map((addr, i) => [String(i), addr])
499
+ : Object.entries(refs)
500
+ if (fieldEntries.length === 0) return h`<div class="empty">${isArray ? '[]' : '{}'}</div>`
501
+ return h`
502
+ <table class="kv clickable">
503
+ <tbody>
504
+ ${fieldEntries.map(([k, childAddr]) => {
505
+ if (childAddr === undefined) {
506
+ const inlineValue = isArray ? decoded[+k] : decoded[k]
507
+ return h`
508
+ <tr>
509
+ <td class="mono">${k}</td>
510
+ <td>${previewValue(inlineValue)}</td>
511
+ <td class="dim">(inline)</td>
512
+ </tr>
513
+ `
514
+ }
515
+ let preview = ''
516
+ try { preview = previewValue(repo.decode(childAddr)) }
517
+ catch { preview = '(error)' }
518
+ return h`
519
+ <tr data-key=${k} data-action="open-at"
520
+ data-keyhex=${keyHex} data-addr=${childAddr}>
521
+ <td class="mono">${k}</td>
522
+ <td>${preview}</td>
523
+ <td class="mono dim">@${childAddr}</td>
524
+ </tr>
525
+ `
526
+ })}
527
+ </tbody>
528
+ </table>
529
+ `
530
+ }
531
+
532
+ // Commit: same direct kv-table format as Object (it *is* an OBJECT —
533
+ // user requested the "dumber" version that names every field rather
534
+ // than packing them into a polished headline). Banner shows the
535
+ // verify state from the covering sig; the verification table at the
536
+ // bottom links to that sig and shows its bytes.
492
537
  if (isCommit) {
538
+ const covering = findCoveringSig(repo, resolvedAddr)
493
539
  const parentDataAddr = decoded.parent !== undefined
494
540
  ? safeGet(() => repo.decode(decoded.parent)?.dataAddress)
495
541
  : undefined
496
542
  const changes = parentDataAddr !== undefined
497
543
  ? [...changedPaths(repo, parentDataAddr, decoded.dataAddress)]
498
544
  : null
545
+ const banner = kindBanner(
546
+ covering ? 'signed commit' : 'commit (unsigned)',
547
+ covering
548
+ ? () => {
549
+ dep()
550
+ const status = verifyStatus(repo, keyHex, covering.decoded, covering.sigAddress)
551
+ return h`${verifyBadge(status)} <span class="dim">${verifyLabel(status)}</span>`
552
+ }
553
+ : h`<span class="verify-badge pending">…</span><span class="dim">not yet signed — sign in flight or pending</span>`,
554
+ covering ? 'verified' : 'unsigned'
555
+ )
499
556
  return h`
557
+ ${selector}
500
558
  ${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>
559
+ ${banner}
560
+ ${refsTable()}
561
+ <h3>rehydrated</h3>
562
+ <pre class="value">${safeJSON(decoded)}</pre>
522
563
  ${changes
523
564
  ? h`
524
565
  <h3>changed paths <span class="dim">(${changes.length})</span></h3>
@@ -527,16 +568,32 @@ function AtView ({ keyHex, address }) {
527
568
  : h`<div class="dim">(no path-level changes — same dataAddress)</div>`}
528
569
  `
529
570
  : null}
530
- <h3>rehydrated</h3>
531
- <pre class="value">${safeJSON(decoded)}</pre>
571
+ ${covering ? h`
572
+ <h3>verification</h3>
573
+ <table class="kv">
574
+ <tbody>
575
+ <tr>
576
+ <td>signature</td>
577
+ <td><a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${covering.sigAddress}>@${covering.sigAddress}</a></td>
578
+ </tr>
579
+ <tr>
580
+ <td>covers</td>
581
+ <td>@${covering.signedFrom} through @${covering.signedTo} (${covering.signedTo - covering.signedFrom + 1} bytes)</td>
582
+ </tr>
583
+ <tr><td>sig bytes</td><td class="mono">${truncHex(covering.decoded.compactRawBytes, 32)}</td></tr>
584
+ </tbody>
585
+ </table>
586
+ ` : null}
587
+ ${repoExtras(repo, keyHex)}
532
588
  `
533
589
  }
534
590
 
535
591
  // Duple: explain what this tree-node IS, then show its two children.
536
592
  if (codecType === 'DUPLE') {
537
593
  return h`
594
+ ${selector}
538
595
  ${tabs}
539
- <div class="dim">codec: DUPLE</div>
596
+ ${kindBanner('duple', h`<span class="dim">2-tuple, tree scaffolding</span>`)}
540
597
  <p class="explainer">
541
598
  A <strong>Duple</strong> is a 2-tuple — the building block streamo uses
542
599
  to balance binary trees of OBJECT entries and ARRAY elements. Each Duple
@@ -555,14 +612,24 @@ function AtView ({ keyHex, address }) {
555
612
  `
556
613
  }
557
614
 
558
- // Signature: the polished "signed commit" view + the unsigned/storage
559
- // listing below. Shared with the storage tab's chunk-detail view.
615
+ // Signature: the sig-detail page (auxiliary in the new model — sigs
616
+ // are how commits are verified, not the user-level unit). Lists
617
+ // the commits this sig covers; pick one to land on its commit page.
560
618
  if (isSig) {
619
+ const banner = kindBanner(
620
+ 'signature chunk',
621
+ () => {
622
+ dep()
623
+ const status = verifyStatus(repo, keyHex, decoded, resolvedAddr)
624
+ return h`${verifyBadge(status)} <span class="dim">${verifyLabel(status)}</span>`
625
+ },
626
+ 'verified'
627
+ )
561
628
  return h`
562
629
  ${selector}
563
630
  ${tabs}
564
- ${signedCommitDetail(repo, keyHex, resolvedAddr)}
565
- ${repoExtras(repo, keyHex)}
631
+ ${banner}
632
+ ${sigDetailBody(repo, keyHex, resolvedAddr, decoded)}
566
633
  <div class="dim" style="margin-top: 0.5rem;">switch to the <strong>storage</strong> tab above to see the raw chunk bytes, outgoing references, and what else points at this address.</div>
567
634
  `
568
635
  }
@@ -570,59 +637,30 @@ function AtView ({ keyHex, address }) {
570
637
  // Object/array: clickable children with their addresses.
571
638
  if (refs && typeof refs === 'object') {
572
639
  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
- }
640
+ const fieldCount = isArray ? refs.length : Object.keys(refs).length
641
+ const dim = fieldCount === 0
642
+ ? null
643
+ : h`<span class="dim">${isArray ? `length ${fieldCount}` : `${fieldCount} field${fieldCount === 1 ? '' : 's'}`}</span>`
644
+ const label = fieldCount === 0
645
+ ? (isArray ? 'empty array' : 'empty object')
646
+ : (isArray ? 'array' : 'object')
583
647
  return h`
648
+ ${selector}
584
649
  ${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>
650
+ ${kindBanner(label, dim)}
651
+ ${refsTable()}
652
+ ${fieldCount > 0 ? h`
653
+ <h3>rehydrated</h3>
654
+ <pre class="value">${safeJSON(decoded)}</pre>
655
+ ` : null}
619
656
  `
620
657
  }
621
658
 
622
659
  // Primitive: just show it.
623
660
  return h`
661
+ ${selector}
624
662
  ${tabs}
625
- <div class="dim">codec: ${codecType}</div>
663
+ ${kindBanner(codecType.toLowerCase())}
626
664
  <pre class="value">${safeJSON(decoded)}</pre>
627
665
  `
628
666
  }}
@@ -925,6 +963,28 @@ function verifyStatus (repo, keyHex, sig, sigAddress) {
925
963
  return 'pending'
926
964
  }
927
965
 
966
+ // Consistent "what this is" banner at the top of every value-tab branch.
967
+ // label is the short codec/role name (e.g. "signed commit", "object",
968
+ // "duple"); content is whatever else goes in the banner (verify badge +
969
+ // label, field count, etc.); variant tints the surface — 'verified' for
970
+ // commits/sigs with a covering signature, 'unsigned' for commits awaiting
971
+ // one, undefined for everything else.
972
+ function kindBanner (label, content, variant) {
973
+ return h`
974
+ <div class=${['kind-banner', variant || null]}>
975
+ <span class="kind-label">${label}</span>
976
+ ${content || null}
977
+ </div>
978
+ `
979
+ }
980
+
981
+ function verifyLabel (status) {
982
+ if (status === 'valid') return 'verified — bytes match this repo’s public key'
983
+ if (status === 'invalid') return 'NOT VERIFIED — bytes do not match the repo key'
984
+ if (status === 'pending') return 'verifying…'
985
+ return `error: ${status?.error ?? 'unknown'}`
986
+ }
987
+
928
988
  function verifyBadge (status) {
929
989
  if (status === 'valid') return h`<span class="verify-badge valid" title="signature verified against repo's public key">✓</span>`
930
990
  if (status === 'invalid') return h`<span class="verify-badge invalid" title="signature does NOT match repo's public key">✗</span>`
@@ -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