@dtudury/streamo 4.0.1 → 4.0.4

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
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/dtudury/streamo/main/public/streamo.svg" alt="streamo" width="140">
3
+ </p>
4
+
1
5
  # streamo
2
6
 
3
7
  > every device is an equal author
@@ -126,6 +130,29 @@ const registry = new RepoRegistry(async key => {
126
130
  const repo = await registry.open(publicKeyHex)
127
131
  ```
128
132
 
133
+ ### bridgeRegistry — connect a multi-repo registry to your app's Recaller
134
+
135
+ Each `Repo` owns its own `Recaller` (so it can do fine-grained tracking on its
136
+ own internal keys), and your app uses a separate `Recaller` for its `mount()`
137
+ slots. Reading `repo.byteLength` inside a slot registers a dep on the *repo's*
138
+ recaller, not the app's, so without an explicit bridge the slot would never
139
+ re-run when chunks arrive. `bridgeRegistry` is that bridge:
140
+
141
+ ```js
142
+ import { Recaller, bridgeRegistry, h, mount } from '@dtudury/streamo'
143
+
144
+ const recaller = new Recaller('app')
145
+ const { dep, fire } = bridgeRegistry(registry, recaller)
146
+
147
+ mount(h`${() => {
148
+ dep()
149
+ for (const [k, r] of registry) ... // freely read any repo's state
150
+ }}`, appEl, recaller)
151
+
152
+ // Non-repo state changes (route, async results) — call fire() to force a re-render.
153
+ window.addEventListener('hashchange', fire)
154
+ ```
155
+
129
156
  ### registrySync — peer sync over WebSocket
130
157
 
131
158
  ```js
@@ -163,7 +190,7 @@ mount(h`
163
190
  `, document.body, recaller)
164
191
  ```
165
192
 
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.
193
+ 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
194
 
168
195
  > **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
196
 
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.1",
3
+ "version": "4.0.4",
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 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>streamo chat</title>
7
+ <link rel="icon" type="image/svg+xml" href="/streamo.svg">
7
8
  <style>
8
9
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0 }
9
10
  :root { font-family: system-ui, sans-serif; font-size: 15px; --bg: #f5f5f5; --surface: #fff; --accent: #0070f3; --border: #ddd }
@@ -11,7 +12,9 @@
11
12
 
12
13
  /* Login */
13
14
  #login { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 2rem; width: min(360px, 90vw); display: flex; flex-direction: column; gap: .75rem }
14
- #login h1 { font-size: 1.2rem; font-weight: 600 }
15
+ #login h1 { font-size: 1.2rem; font-weight: 600; display: flex; align-items: center; gap: .5rem }
16
+ #login h1 img { width: 1.4rem; height: 1.4rem }
17
+ #chat-header img { width: 1.1rem; height: 1.1rem }
15
18
  #login input { border: 1px solid var(--border); border-radius: 6px; padding: .5rem .75rem; font-size: 1rem; width: 100% }
16
19
  #login button { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: .6rem; font-size: 1rem; cursor: pointer }
17
20
  #login button:hover { opacity: .85 }
@@ -38,7 +41,7 @@
38
41
  <body>
39
42
 
40
43
  <div id="login">
41
- <h1>streamo chat</h1>
44
+ <h1><img src="/streamo.svg" alt="">streamo chat</h1>
42
45
  <input id="username" placeholder="username" autocomplete="username">
43
46
  <input id="password" type="password" placeholder="password" autocomplete="current-password">
44
47
  <button id="join-btn">join</button>
@@ -47,7 +50,7 @@
47
50
 
48
51
  <div id="chat">
49
52
  <div id="chat-header">
50
- streamo chat <span id="my-name"></span>
53
+ <img src="/streamo.svg" alt="">streamo chat <span id="my-name"></span>
51
54
  </div>
52
55
  <div id="messages"></div>
53
56
  <div id="input-row">
@@ -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())
@@ -81,30 +82,25 @@ joinBtn.onclick = async () => {
81
82
  // ── Reactive message list ──────────────────────────────────────────────
82
83
  //
83
84
  // Each repo has its own internal Recaller, so repo.get() inside a mount
84
- // slot won't automatically re-trigger mount's recaller. Bridge via
85
- // reportKey*: repo.watch() calls reportKeyMutation when data changes;
86
- // 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.
87
89
 
88
90
  const recaller = new Recaller('chat')
89
- const signal = {}
90
-
91
- function triggerRender () {
92
- 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()
93
99
  requestAnimationFrame(() => { msgsEl.scrollTop = msgsEl.scrollHeight })
94
- }
95
-
96
- function watchRepo (keyHex, repo) {
97
- repo.watch(`chat:${keyHex}`, () => {
98
- repo.byteLength // register 'length' dep → re-fires on every commit and incoming sync chunk
99
- triggerRender()
100
- })
101
- }
102
-
103
- for (const [k, r] of registry) watchRepo(k, r)
104
- registry.onOpen((keyHex, repo) => { watchRepo(keyHex, repo); triggerRender() })
100
+ })
105
101
 
106
102
  mount(h`${function messages () {
107
- recaller.reportKeyAccess(signal, 'data')
103
+ dep()
108
104
  const all = []
109
105
  for (const [keyHex, repo] of registry) {
110
106
  if (keyHex === rootKey) continue
@@ -4,12 +4,20 @@
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" type="image/svg+xml" href="/streamo.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; }
10
11
 
11
12
  .header { display: flex; align-items: baseline; gap: 0.75rem; margin-bottom: 0.25rem; }
12
- .wordmark { font-size: 1.6rem; letter-spacing: -0.02em; }
13
+ .wordmark {
14
+ display: flex;
15
+ align-items: center;
16
+ gap: 0.5rem;
17
+ font-size: 1.6rem;
18
+ letter-spacing: -0.02em;
19
+ }
20
+ .wordmark img { width: 1.8rem; height: 1.8rem; }
13
21
  .crumbs { font-size: 0.85rem; color: var(--ink-dim); }
14
22
  .back { cursor: pointer; color: var(--ink-dim); font-size: 0.85rem; display: inline-block; margin-bottom: 1rem; }
15
23
  .back:hover { color: var(--ink); }
@@ -220,6 +228,19 @@
220
228
  }
221
229
  .repo-link:hover { background: var(--flash); text-decoration-style: solid; }
222
230
 
231
+ /* Sticky at-view header: selector + strip + tabs travel with you as
232
+ you scroll long value trees or storage detail. Background-cover so
233
+ content scrolling underneath doesn't bleed through. */
234
+ .atview-header {
235
+ position: sticky;
236
+ top: 0;
237
+ z-index: 10;
238
+ background: var(--bg, #fefdf8);
239
+ padding-top: 0.25rem;
240
+ border-bottom: 1px solid var(--rule);
241
+ margin-bottom: 0.75rem;
242
+ }
243
+
223
244
  /* Byte stream — zoomed strip in a horizontally-scrollable container,
224
245
  click-drag-to-pan inside for "look around" navigation. */
225
246
  .byte-strip-container {
@@ -235,6 +256,45 @@
235
256
  .byte-strip-container.dragging .chunk { cursor: grabbing; }
236
257
  .byte-strip { display: block; }
237
258
 
259
+ /* Sig-coverage overlay: when hovering a sig anywhere on the page,
260
+ this rect is positioned over its [signedFrom, signedTo] byte range
261
+ on the strip. Subtle dashed band — doesn't fight the chunk colors. */
262
+ .byte-strip .sig-coverage {
263
+ fill: rgba(239, 68, 68, 0.12);
264
+ stroke: rgba(239, 68, 68, 0.6);
265
+ stroke-width: 1.5;
266
+ stroke-dasharray: 4 3;
267
+ opacity: 0;
268
+ transition: opacity 0.08s;
269
+ }
270
+ .byte-strip .sig-coverage.active { opacity: 1; }
271
+
272
+ /* "← older newer →" labels under the strip — no UI action, just
273
+ Tour-Guide orientation so the append direction is obvious. */
274
+ .strip-direction {
275
+ display: flex;
276
+ justify-content: space-between;
277
+ font-size: 0.7rem;
278
+ color: var(--ink-dim);
279
+ padding: 0 0.25rem;
280
+ margin-top: 0.15rem;
281
+ }
282
+
283
+ /* Live readout of the hovered chunk — codec, address, length. Quiet
284
+ by default; lights up when something's hovered. */
285
+ .chunk-inspector {
286
+ font-family: monospace;
287
+ font-size: 0.8rem;
288
+ padding: 0.25rem 0.5rem;
289
+ margin: 0.25rem 0 0.75rem;
290
+ border-radius: var(--radius);
291
+ transition: color 0.08s, background 0.08s;
292
+ }
293
+ .chunk-inspector.active {
294
+ color: var(--ink);
295
+ background: var(--flash);
296
+ }
297
+
238
298
  .byte-map {
239
299
  display: block;
240
300
  }
@@ -248,6 +308,65 @@
248
308
  .byte-map .chunk.current { stroke: var(--ink); stroke-width: 2; }
249
309
  .byte-map .chunk.hovered { fill-opacity: 0.55; }
250
310
 
311
+ /* Streamo-typed value pills — every value gets a type-specific visual
312
+ identity instead of flattening through JSON.stringify. Colors echo
313
+ the byte-strip codec palette below so the visual language carries
314
+ across the page. */
315
+ .tv {
316
+ display: inline-flex;
317
+ align-items: center;
318
+ gap: 0.25rem;
319
+ padding: 0.05rem 0.4rem;
320
+ border-radius: var(--radius);
321
+ font-size: 0.85rem;
322
+ max-width: 100%;
323
+ vertical-align: baseline;
324
+ }
325
+ .tv-string { color: #047857; background: rgba(16, 185, 129, 0.10); font-family: monospace; }
326
+ .tv-string .tv-quote { color: #10b981; opacity: 0.7; font-weight: 600; }
327
+ .tv-num { color: #475569; background: rgba(100, 116, 139, 0.10); font-family: monospace; }
328
+ .tv-date { color: #475569; background: rgba(100, 116, 139, 0.10); }
329
+ .tv-date .tv-glyph { font-size: 0.75rem; }
330
+ .tv-date time { font-variant-numeric: tabular-nums; }
331
+ .tv-bool.tv-true { color: #15803d; background: rgba(22, 163, 74, 0.10); font-family: monospace; }
332
+ .tv-bool.tv-false { color: #b91c1c; background: rgba(220, 38, 38, 0.10); font-family: monospace; }
333
+ .tv-null, .tv-undefined { color: var(--ink-dim); background: transparent; font-style: italic; font-family: monospace; }
334
+ .tv-bytes { color: #4d7c0f; background: rgba(132, 204, 22, 0.10); font-family: monospace; }
335
+ .tv-array, .tv-object { color: #1e40af; background: rgba(59, 130, 246, 0.10); }
336
+ .tv-duple { color: #6b21a8; background: rgba(168, 85, 247, 0.10); }
337
+
338
+ /* Recursive typed-value tree — used for rehydrated views. Outer levels
339
+ expand inline; un-expanded composites become drillable chips. */
340
+ .tv-tree {
341
+ font-size: 0.85rem;
342
+ line-height: 1.7;
343
+ font-family: monospace;
344
+ margin: 0.4rem 0;
345
+ }
346
+ .tv-tree-row {
347
+ padding-left: 1.5rem;
348
+ }
349
+ .tv-tree .tv-bracket {
350
+ color: var(--ink-dim);
351
+ font-weight: 600;
352
+ }
353
+ .tv-tree .tv-bracket.clickable {
354
+ cursor: pointer;
355
+ }
356
+ .tv-tree .tv-bracket.clickable:hover {
357
+ background: var(--flash);
358
+ color: var(--ink);
359
+ }
360
+ .tv-tree .tv-key {
361
+ color: var(--ink-dim);
362
+ margin-right: 0.4rem;
363
+ }
364
+ .tv-drill {
365
+ cursor: pointer;
366
+ text-decoration: underline dotted var(--ink-dim);
367
+ }
368
+ .tv-drill:hover { background: var(--flash); text-decoration-style: solid; }
369
+
251
370
  /* codec category palette — used in both the legend and the SVG fills */
252
371
  .cat-commit { fill: #f59e0b; background: #f59e0b; }
253
372
  .cat-sig { fill: #ef4444; background: #ef4444; }
@@ -316,7 +435,7 @@
316
435
  </head>
317
436
  <body>
318
437
  <div class="header">
319
- <div class="wordmark">streamo</div>
438
+ <div class="wordmark"><img src="/streamo.svg" alt="streamo">streamo</div>
320
439
  <div class="crumbs">explorer</div>
321
440
  </div>
322
441
  <div id="conn" class="conn">connecting…</div>
@@ -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,36 +45,18 @@ 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
- })
64
- }
65
-
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
- })
55
+ bridgeFire()
56
+ if (stripSyncScheduled) return
57
+ stripSyncScheduled = true
58
+ requestAnimationFrame(() => { stripSyncScheduled = false; syncByteStrips() })
74
59
  }
75
- for (const [k, r] of registry) watchRepo(k, r)
76
- registry.onOpen((k, r) => { watchRepo(k, r); fire() })
77
60
 
78
61
  // ── Hash routing ──────────────────────────────────────────────────────────
79
62
 
@@ -298,11 +281,18 @@ function RegistryView () {
298
281
  dep()
299
282
  const rows = []
300
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.
301
289
  const last = repo.lastCommit
290
+ const len = repo.byteLength
291
+ const when = last ? fmtDate(last.date) : `${len} b`
302
292
  rows.push(h`
303
293
  <div class="row" data-key=${keyHex} data-action="open-repo">
304
294
  <span class="mono">${truncKey(keyHex)}</span>
305
- <span class="when">${last ? fmtDate(last.date) : '(no commits)'}</span>
295
+ <span class=${['when', last ? null : 'dim']}>${when}</span>
306
296
  <span class="msg dim">${last?.message || ''}</span>
307
297
  </div>
308
298
  `)
@@ -407,7 +397,7 @@ function repoExtras (repo, keyHex) {
407
397
  <tr data-key=${`o${e.address}`} data-action="open-at"
408
398
  data-keyhex=${keyHex} data-addr=${e.address}>
409
399
  <td class="mono dim">${e.codecType}</td>
410
- <td>${(() => { try { return previewValue(repo.decode(e.address)) } catch { return '' } })()}</td>
400
+ <td>${(() => { try { return typedValue(repo.decode(e.address)) } catch { return '' } })()}</td>
411
401
  <td class="mono dim">@${e.address}</td>
412
402
  </tr>
413
403
  `)}
@@ -453,11 +443,13 @@ function AtView ({ keyHex, address }) {
453
443
  const isCommit = isCommitShape(decoded)
454
444
  const isSig = codecType === 'SIGNATURE'
455
445
 
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".
446
+ // Common header shown on every at-view: commit selector dropdown,
447
+ // byte-strip with the current chunk highlighted, then the value/
448
+ // storage tab nav. The byte-strip used to live only in the storage
449
+ // tab; promoting it lets you keep spatial context across tab and
450
+ // commit switches, and any data-addr hover (typed-tree chips,
451
+ // refs/referrers tables, kv addr links) cross-highlights and
452
+ // smooth-scrolls into view in the strip.
461
453
  const tabs = h`
462
454
  <nav class="tabs">
463
455
  <a class=${() => { dep(); return ['tab', atTab === 'value' ? 'active' : null] }}
@@ -467,24 +459,25 @@ function AtView ({ keyHex, address }) {
467
459
  </nav>
468
460
  `
469
461
  const selector = commitSelectorSection(repo, keyHex, resolvedAddr)
462
+ const bytes = byteStreamSection(repo, keyHex, resolvedAddr)
463
+ // Wrap in a sticky container so the selector + strip + tabs stay
464
+ // anchored as you scroll long value trees or storage detail.
465
+ const header = h`<div class="atview-header">${selector}${bytes}${tabs}</div>`
470
466
 
471
- // Storage tab: spatial view of where this chunk lives in the byte
472
- // stream + outgoing references + this chunk's bytes + incoming
473
- // referrers. The chunk graph from this chunk's perspective.
467
+ // Storage tab: this chunk's outgoing refs, raw bytes, and
468
+ // referrers. (byteStreamSection moved up into the header.)
474
469
  if (atTab === 'storage') {
475
470
  return h`
476
- ${selector}
477
- ${tabs}
478
- ${byteStreamSection(repo, keyHex, resolvedAddr)}
471
+ ${header}
479
472
  ${outgoingReferencesSection(repo, keyHex, resolvedAddr)}
480
473
  ${rawChunkSection(repo, resolvedAddr)}
481
474
  ${referrersSection(repo, keyHex, resolvedAddr)}
482
475
  `
483
476
  }
484
477
 
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.
478
+ // Every value-tab branch below prepends ${header} so the UI is
479
+ // stable across navigation: selector + byte-strip + tabs are
480
+ // always at the top of the page when the repo has any commits.
488
481
 
489
482
  // Value tab — branches by codec.
490
483
  // Helper: render the kv-table of decoded fields for any Object/Array
@@ -507,13 +500,13 @@ function AtView ({ keyHex, address }) {
507
500
  return h`
508
501
  <tr>
509
502
  <td class="mono">${k}</td>
510
- <td>${previewValue(inlineValue)}</td>
503
+ <td>${typedValue(inlineValue)}</td>
511
504
  <td class="dim">(inline)</td>
512
505
  </tr>
513
506
  `
514
507
  }
515
508
  let preview = ''
516
- try { preview = previewValue(repo.decode(childAddr)) }
509
+ try { preview = typedValue(repo.decode(childAddr)) }
517
510
  catch { preview = '(error)' }
518
511
  return h`
519
512
  <tr data-key=${k} data-action="open-at"
@@ -553,13 +546,30 @@ function AtView ({ keyHex, address }) {
553
546
  : h`<span class="verify-badge pending">…</span><span class="dim">not yet signed — sign in flight or pending</span>`,
554
547
  covering ? 'verified' : 'unsigned'
555
548
  )
549
+ // Commit fields render with two semantic specials: dataAddress and
550
+ // parent are *byte-address pointers* (their numeric value IS a
551
+ // navigation target), so they're clickable address pills directly
552
+ // — the chunk holding the FLOAT64 value is incidental and we
553
+ // skip the chunk-address column for those rows.
554
+ const addrLink = (addr) => addr === undefined
555
+ ? h`<span class="dim">(none — first commit)</span>`
556
+ : h`<a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${addr}>@${addr}</a>`
557
+ const commitFieldsTable = h`
558
+ <table class="kv">
559
+ <tbody>
560
+ <tr><td class="mono">message</td><td>${typedValue(decoded.message)}</td></tr>
561
+ <tr><td class="mono">date</td><td>${typedValue(decoded.date)}</td></tr>
562
+ <tr><td class="mono">dataAddress</td><td>${addrLink(decoded.dataAddress)}</td></tr>
563
+ <tr><td class="mono">parent</td><td>${addrLink(decoded.parent)}</td></tr>
564
+ </tbody>
565
+ </table>
566
+ `
556
567
  return h`
557
- ${selector}
558
- ${tabs}
568
+ ${header}
559
569
  ${banner}
560
- ${refsTable()}
561
- <h3>rehydrated</h3>
562
- <pre class="value">${safeJSON(decoded)}</pre>
570
+ ${commitFieldsTable}
571
+ <h3>value <span class="dim">at <a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${decoded.dataAddress}>@${decoded.dataAddress}</a></span></h3>
572
+ ${valueTree(repo, keyHex, decoded.dataAddress)}
563
573
  ${changes
564
574
  ? h`
565
575
  <h3>changed paths <span class="dim">(${changes.length})</span></h3>
@@ -591,8 +601,7 @@ function AtView ({ keyHex, address }) {
591
601
  // Duple: explain what this tree-node IS, then show its two children.
592
602
  if (codecType === 'DUPLE') {
593
603
  return h`
594
- ${selector}
595
- ${tabs}
604
+ ${header}
596
605
  ${kindBanner('duple', h`<span class="dim">2-tuple, tree scaffolding</span>`)}
597
606
  <p class="explainer">
598
607
  A <strong>Duple</strong> is a 2-tuple — the building block streamo uses
@@ -605,8 +614,8 @@ function AtView ({ keyHex, address }) {
605
614
  </p>
606
615
  <table class="kv">
607
616
  <tbody>
608
- <tr><td class="mono">v[0]</td><td>${previewValue(decoded.v[0])}</td></tr>
609
- <tr><td class="mono">v[1]</td><td>${previewValue(decoded.v[1])}</td></tr>
617
+ <tr><td class="mono">v[0]</td><td>${typedValue(decoded.v[0])}</td></tr>
618
+ <tr><td class="mono">v[1]</td><td>${typedValue(decoded.v[1])}</td></tr>
610
619
  </tbody>
611
620
  </table>
612
621
  `
@@ -626,8 +635,7 @@ function AtView ({ keyHex, address }) {
626
635
  'verified'
627
636
  )
628
637
  return h`
629
- ${selector}
630
- ${tabs}
638
+ ${header}
631
639
  ${banner}
632
640
  ${sigDetailBody(repo, keyHex, resolvedAddr, decoded)}
633
641
  <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>
@@ -645,8 +653,7 @@ function AtView ({ keyHex, address }) {
645
653
  ? (isArray ? 'empty array' : 'empty object')
646
654
  : (isArray ? 'array' : 'object')
647
655
  return h`
648
- ${selector}
649
- ${tabs}
656
+ ${header}
650
657
  ${kindBanner(label, dim)}
651
658
  ${refsTable()}
652
659
  ${fieldCount > 0 ? h`
@@ -658,8 +665,7 @@ function AtView ({ keyHex, address }) {
658
665
 
659
666
  // Primitive: just show it.
660
667
  return h`
661
- ${selector}
662
- ${tabs}
668
+ ${header}
663
669
  ${kindBanner(codecType.toLowerCase())}
664
670
  <pre class="value">${safeJSON(decoded)}</pre>
665
671
  `
@@ -713,12 +719,22 @@ function byteStreamSection (repo, keyHex, currentAddress) {
713
719
  const code = repo.resolve(addr)
714
720
  if (!code || !code.length) break
715
721
  const codec = repo.footerToCodec[code.at(-1)]
716
- chunks.unshift({
722
+ const chunk = {
717
723
  address: addr,
718
724
  start: addr - code.length + 1,
719
725
  length: code.length,
720
726
  codecType: codec?.type || '?'
721
- })
727
+ }
728
+ // For sigs: precompute the byte range covered, so hover anywhere on
729
+ // the page can light up that range as an overlay band on the strip.
730
+ if (chunk.codecType === 'SIGNATURE') {
731
+ try {
732
+ const sig = repo.decode(addr)
733
+ chunk.signedFrom = sig.address
734
+ chunk.signedTo = addr - code.length
735
+ } catch {}
736
+ }
737
+ chunks.unshift(chunk)
722
738
  addr -= code.length
723
739
  }
724
740
  if (!chunks.length) return null
@@ -754,6 +770,20 @@ function byteStreamSection (repo, keyHex, currentAddress) {
754
770
  return item
755
771
  })
756
772
  const stripW = cursorX
773
+ // Map byte address → strip x. Used by the sig-coverage overlay so hover
774
+ // anywhere on the page can light up "what bytes does this sig sign".
775
+ // Stored as data attrs on the strip container so the hover handler
776
+ // can read without recomputing.
777
+ const xForByte = (byteAddr) => {
778
+ // Find the chunk containing this byte and interpolate within it.
779
+ for (const c of layout) {
780
+ if (byteAddr >= c.start && byteAddr <= c.address) {
781
+ const frac = c.length === 1 ? 0 : (byteAddr - c.start) / (c.length - 1)
782
+ return c.x + frac * c.w
783
+ }
784
+ }
785
+ return 0
786
+ }
757
787
  return h`
758
788
  <h3>byte stream <span class="dim">(${total} bytes · ${chunks.length} chunks)</span></h3>
759
789
  <div class="byte-map-legend">
@@ -766,21 +796,33 @@ function byteStreamSection (repo, keyHex, currentAddress) {
766
796
  <span class="cat-num">num</span>
767
797
  <span class="cat-var">var</span>
768
798
  </div>
769
- <div class="byte-strip-container" data-key=${`strip-${keyHex}`}>
799
+ <div class="byte-strip-container" data-key=${`strip-${keyHex}`} data-strip-w=${stripW}>
770
800
  <svg class="byte-map byte-strip" width=${stripW} height=${H} viewBox=${`0 0 ${stripW} ${H}`}>
771
801
  ${layout.map(c => {
772
802
  const cat = commitAddrs.has(c.address) ? 'commit' : codecCategory(c.codecType)
773
803
  const cls = ['chunk', `cat-${cat}`, c.address === currentAddress ? 'current' : null]
804
+ // Sigs carry their coverage range in data-attrs so hover handlers
805
+ // (anywhere on the page) can position the coverage overlay.
806
+ // Non-sigs get null which removes the attrs.
807
+ const sigFromX = c.signedFrom != null ? xForByte(c.signedFrom) : null
808
+ const sigToX = c.signedTo != null ? xForByte(c.signedTo) : null
774
809
  return h`<rect
775
810
  class=${cls}
776
811
  x=${c.x} y="0" width=${c.w} height=${H}
777
812
  data-action="open-at"
778
813
  data-keyhex=${keyHex}
779
814
  data-addr=${c.address}
815
+ data-codec=${c.codecType}
816
+ data-len=${c.length}
817
+ data-sig-from-x=${sigFromX}
818
+ data-sig-to-x=${sigToX}
780
819
  ><title>${c.codecType} @${c.address} (${c.length} bytes)</title></rect>`
781
820
  })}
821
+ <rect class="sig-coverage" x="0" y="0" width="0" height=${H} pointer-events="none"/>
782
822
  </svg>
823
+ <div class="strip-direction"><span>← older</span><span>newer →</span></div>
783
824
  </div>
825
+ <div class="chunk-inspector dim" data-key=${`inspector-${keyHex}`}>hover the strip to inspect a chunk</div>
784
826
  `
785
827
  }
786
828
 
@@ -802,7 +844,7 @@ function outgoingReferencesSection (repo, keyHex, address) {
802
844
  try {
803
845
  const childCode = repo.resolve(childAddr)
804
846
  codecType = repo.footerToCodec[childCode.at(-1)]?.type || '?'
805
- preview = previewValue(repo.decode(childAddr))
847
+ preview = typedValue(repo.decode(childAddr))
806
848
  } catch { preview = '(error)' }
807
849
  return h`
808
850
  <tr data-key=${`out${i}@${childAddr}`} data-action="open-at"
@@ -840,7 +882,7 @@ function referrersSection (repo, keyHex, address) {
840
882
  <tbody>
841
883
  ${refs.map(r => {
842
884
  let preview = ''
843
- try { preview = previewValue(repo.decode(r.address)) }
885
+ try { preview = typedValue(repo.decode(r.address)) }
844
886
  catch { preview = '(error)' }
845
887
  return h`
846
888
  <tr data-key=${`r${r.address}`} data-action="open-at"
@@ -863,19 +905,108 @@ function isDuple (v) {
863
905
  return v && typeof v === 'object' && Array.isArray(v.v) && v.v.length === 2 && Object.keys(v).length === 1
864
906
  }
865
907
 
866
- function previewValue (v, depth = 0) {
867
- if (v == null) return String(v)
868
- if (typeof v === 'string') return v.length > 60 ? JSON.stringify(v.slice(0, 60)) + '…' : JSON.stringify(v)
869
- if (typeof v === 'number' || typeof v === 'boolean') return String(v)
870
- if (v instanceof Date) return v.toISOString()
871
- if (v instanceof Uint8Array) return `Uint8Array(${v.length})`
908
+ // Streamo-typed value renderer every value gets a visual identity
909
+ // matching its underlying codec, instead of being flattened through
910
+ // JSON.stringify. Primitives render with type-specific styling
911
+ // (string quoted mono in green frame, date <time> with calendar
912
+ // chip, number number chip, etc.); composites currently render as
913
+ // count chips ({ N fields } / [ N elements ]) depth-controlled
914
+ // expansion is the next step in this thread (see THREADS.md).
915
+ function typedValue (v, depth = 0) {
916
+ if (v === null) return h`<span class="tv tv-null" title="NULL">null</span>`
917
+ if (v === undefined) return h`<span class="tv tv-undefined" title="UNDEFINED">undefined</span>`
918
+ if (typeof v === 'boolean') {
919
+ return h`<span class=${['tv', 'tv-bool', v ? 'tv-true' : 'tv-false']} title=${v ? 'TRUE' : 'FALSE'}>${v ? '✓' : '✗'} ${String(v)}</span>`
920
+ }
921
+ if (typeof v === 'string') {
922
+ const display = v.length > 60 ? v.slice(0, 60) + '…' : v
923
+ return h`<span class="tv tv-string" title=${v.length === 0 ? 'EMPTY_STRING' : 'STRING'}><span class="tv-quote">“</span>${display}<span class="tv-quote">”</span></span>`
924
+ }
925
+ if (typeof v === 'number') {
926
+ // UINT7 is the codec for non-negative integers < 128; everything else
927
+ // routes through FLOAT64. Surfacing this distinction makes "why is
928
+ // this 1 byte vs 9" tactile when you hover.
929
+ const codec = (Number.isInteger(v) && v >= 0 && v < 128) ? 'UINT7' : 'FLOAT64'
930
+ return h`<span class="tv tv-num" title=${codec}>${String(v)}</span>`
931
+ }
932
+ if (v instanceof Date) {
933
+ return h`<span class="tv tv-date" title="DATE"><span class="tv-glyph">📅</span><time datetime=${v.toISOString()}>${v.toLocaleString()}</time></span>`
934
+ }
935
+ if (v instanceof Uint8Array) {
936
+ return h`<span class="tv tv-bytes" title=${v.length === 0 ? 'EMPTY_UINT8ARRAY' : (v.length <= 4 ? 'WORD or UINT8ARRAY' : 'UINT8ARRAY')}>Uint8Array(${v.length})</span>`
937
+ }
872
938
  if (isDuple(v)) {
873
- if (depth > 2) return 'Duple(…)'
874
- return `Duple(${previewValue(v.v[0], depth + 1)}, ${previewValue(v.v[1], depth + 1)})`
939
+ if (depth > 1) return h`<span class="tv tv-duple" title="DUPLE">Duple(…)</span>`
940
+ return h`<span class="tv tv-duple" title="DUPLE">Duple(${typedValue(v.v[0], depth + 1)}, ${typedValue(v.v[1], depth + 1)})</span>`
875
941
  }
876
- if (Array.isArray(v)) return `[…] (${v.length})`
877
- if (typeof v === 'object') return `{} (${Object.keys(v).length})`
878
- return String(v)
942
+ if (Array.isArray(v)) {
943
+ return h`<span class="tv tv-array" title=${v.length === 0 ? 'EMPTY_ARRAY' : 'ARRAY'}>[ ${v.length} ${v.length === 1 ? 'element' : 'elements'} ]</span>`
944
+ }
945
+ if (typeof v === 'object') {
946
+ const n = Object.keys(v).length
947
+ return h`<span class="tv tv-object" title=${n === 0 ? 'EMPTY_OBJECT' : 'OBJECT'}>{ ${n} ${n === 1 ? 'field' : 'fields'} }</span>`
948
+ }
949
+ return h`<span class="tv">${String(v)}</span>`
950
+ }
951
+
952
+ // Recursive typed-value tree — like typedValue, but expands composites
953
+ // inline up to `depth` levels deep. Beyond depth, composites render as
954
+ // un-expanded chips. Click a chip to expand IN PLACE (forceExpanded);
955
+ // click an expanded composite's opening bracket to collapse it back to
956
+ // a chip (forceCollapsed). Force-expand and force-collapse override
957
+ // the default depth-based decision.
958
+ //
959
+ // Default depth=3 covers `{ name, messages: [{text, at}, ...] }` —
960
+ // outer object expanded, messages array expanded, message objects
961
+ // expanded, and primitives like text/at render inline.
962
+ const forceExpanded = new Set() // `${keyHex}:${address}` → user clicked chip
963
+ const forceCollapsed = new Set() // `${keyHex}:${address}` → user clicked bracket
964
+
965
+ function valueTree (repo, keyHex, address, depth = 3) {
966
+ let value, refs
967
+ try {
968
+ value = repo.decode(address)
969
+ refs = repo.asRefs(address)
970
+ } catch {
971
+ return h`<span class="dim">(decode error @${address})</span>`
972
+ }
973
+ if (typeof value !== 'object' || value === null || value instanceof Date || value instanceof Uint8Array) {
974
+ return typedValue(value)
975
+ }
976
+ const k = `${keyHex}:${address}`
977
+ const userExpanded = forceExpanded.has(k)
978
+ const userCollapsed = forceCollapsed.has(k)
979
+ const expand = userExpanded || (!userCollapsed && depth > 0)
980
+ if (!expand) {
981
+ return h`<a class="tv-drill" data-action="expand-tree"
982
+ data-keyhex=${keyHex} data-addr=${address}
983
+ title="click to expand · drill via storage tab if you need a full at-view"
984
+ >${typedValue(value)}</a>`
985
+ }
986
+ const isArray = Array.isArray(value)
987
+ const entries = isArray
988
+ ? value.map((v, i) => [String(i), v, refs?.[i]])
989
+ : Object.entries(value).map(([k, v]) => [k, v, refs?.[k]])
990
+ if (entries.length === 0) {
991
+ return h`<span class="tv ${isArray ? 'tv-array' : 'tv-object'}">${isArray ? '[ ]' : '{ }'}</span>`
992
+ }
993
+ return h`
994
+ <div class="tv-tree ${isArray ? 'tv-tree-array' : 'tv-tree-object'}">
995
+ <span class="tv-bracket clickable" data-action="collapse-tree"
996
+ data-keyhex=${keyHex} data-addr=${address}
997
+ title="click to collapse"
998
+ >${isArray ? '[' : '{'}</span>
999
+ ${entries.map(([k, v, addr]) => h`
1000
+ <div class="tv-tree-row">
1001
+ <span class="tv-key">${k}:</span>
1002
+ ${addr !== undefined
1003
+ ? valueTree(repo, keyHex, addr, depth - 1)
1004
+ : typedValue(v)}
1005
+ </div>
1006
+ `)}
1007
+ <span class="tv-bracket">${isArray ? ']' : '}'}</span>
1008
+ </div>
1009
+ `
879
1010
  }
880
1011
 
881
1012
  function safeGet (f) { try { return f() } catch { return undefined } }
@@ -1050,6 +1181,18 @@ appEl.addEventListener('click', e => {
1050
1181
  el.closest('details.commit-selector')?.removeAttribute('open')
1051
1182
  return go({ kind: 'at', keyHex: el.dataset.keyhex, address: +el.dataset.addr })
1052
1183
  }
1184
+ case 'expand-tree': {
1185
+ const k = `${el.dataset.keyhex}:${el.dataset.addr}`
1186
+ forceExpanded.add(k)
1187
+ forceCollapsed.delete(k)
1188
+ return fire()
1189
+ }
1190
+ case 'collapse-tree': {
1191
+ const k = `${el.dataset.keyhex}:${el.dataset.addr}`
1192
+ forceCollapsed.add(k)
1193
+ forceExpanded.delete(k)
1194
+ return fire()
1195
+ }
1053
1196
  }
1054
1197
  })
1055
1198
 
@@ -1111,11 +1254,11 @@ appEl.addEventListener('pointerup', endDrag)
1111
1254
  appEl.addEventListener('pointercancel', endDrag)
1112
1255
 
1113
1256
  // Cross-highlight: hovering any element with data-addr highlights the
1114
- // matching chunk in the byte-map. References and referrers light up the
1115
- // chunk's position in the stream so you can SEE where it lives. If the
1116
- // hover came from somewhere other than the strip itself, smooth-scroll
1117
- // the matching chunk into view inside any byte-strip-container —
1118
- // otherwise hover-elsewhere can highlight chunks that are off-screen.
1257
+ // matching chunk in the byte-map, populates the chunk inspector below
1258
+ // the strip with codec/addr/length, and (if the hovered chunk is a
1259
+ // signature) lights up its covered byte range as an overlay band on
1260
+ // the strip. If the hover came from somewhere other than the strip
1261
+ // itself, smooth-scroll the matching chunk into view in the strip.
1119
1262
  appEl.addEventListener('mouseover', e => {
1120
1263
  const el = e.target.closest('[data-addr]')
1121
1264
  if (!el) return
@@ -1129,10 +1272,37 @@ appEl.addEventListener('mouseover', e => {
1129
1272
  }
1130
1273
  })
1131
1274
  }
1275
+ // Look up the chunk's data on the strip rect for inspector + sig coverage.
1276
+ const stripRect = matches[0]?.closest('.byte-strip-container') ? matches[0]
1277
+ : appEl.querySelector(`.byte-strip .chunk[data-addr="${addr}"]`)
1278
+ if (stripRect) {
1279
+ const codec = stripRect.dataset.codec
1280
+ const len = stripRect.dataset.len
1281
+ for (const ins of appEl.querySelectorAll('.chunk-inspector')) {
1282
+ ins.textContent = codec ? `${codec} · @${addr} · ${len} bytes` : `chunk @${addr}`
1283
+ ins.classList.remove('dim')
1284
+ ins.classList.add('active')
1285
+ }
1286
+ const fromX = stripRect.getAttribute('data-sig-from-x')
1287
+ const toX = stripRect.getAttribute('data-sig-to-x')
1288
+ if (fromX != null && toX != null) {
1289
+ const overlay = stripRect.closest('.byte-strip').querySelector('.sig-coverage')
1290
+ if (overlay) {
1291
+ overlay.setAttribute('x', fromX)
1292
+ overlay.setAttribute('width', String(parseFloat(toX) - parseFloat(fromX)))
1293
+ overlay.classList.add('active')
1294
+ }
1295
+ }
1296
+ }
1132
1297
  })
1133
1298
  appEl.addEventListener('mouseout', e => {
1134
1299
  const el = e.target.closest('[data-addr]')
1135
1300
  if (!el) return
1136
- appEl.querySelectorAll('.byte-map .chunk.hovered')
1137
- .forEach(c => c.classList.remove('hovered'))
1301
+ appEl.querySelectorAll('.byte-map .chunk.hovered').forEach(c => c.classList.remove('hovered'))
1302
+ appEl.querySelectorAll('.sig-coverage.active').forEach(o => o.classList.remove('active'))
1303
+ for (const ins of appEl.querySelectorAll('.chunk-inspector')) {
1304
+ ins.textContent = 'hover the strip to inspect a chunk'
1305
+ ins.classList.remove('active')
1306
+ ins.classList.add('dim')
1307
+ }
1138
1308
  })
package/public/index.html CHANGED
@@ -4,18 +4,56 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>streamo</title>
7
+ <link rel="icon" type="image/svg+xml" href="/streamo.svg">
7
8
  <link rel="stylesheet" href="/apps/styles/proto.css">
8
9
  <style>
9
10
  body { max-width: 44rem; margin: 0 auto; padding: 2.5rem 1.25rem; }
10
11
 
11
- .wordmark { font-size: 2.4rem; letter-spacing: -0.02em; margin-bottom: 0.15rem; }
12
- .tagline { color: var(--ink-dim); font-size: 0.95rem; margin-bottom: 2rem; }
12
+ .wordmark {
13
+ display: flex;
14
+ align-items: center;
15
+ gap: 0.6rem;
16
+ font-size: 2.4rem;
17
+ letter-spacing: -0.02em;
18
+ margin-bottom: 0.15rem;
19
+ }
20
+ .wordmark img { width: 2.6rem; height: 2.6rem; }
21
+ .tagline { color: var(--ink-dim); font-size: 0.95rem; margin-bottom: 0.4rem; }
22
+
23
+ /* "what's running here" line — small, clickable, places the abstract
24
+ claims in the next paragraph onto a real running server. */
25
+ .here {
26
+ font-size: 0.78rem;
27
+ color: var(--ink-dim);
28
+ margin-bottom: 2rem;
29
+ font-family: monospace;
30
+ }
31
+ .here a {
32
+ color: var(--ink-dim);
33
+ text-decoration: underline dotted;
34
+ }
35
+ .here a:hover { color: var(--ink); text-decoration-style: solid; }
36
+ .here .pulse {
37
+ display: inline-block;
38
+ width: 0.5rem;
39
+ height: 0.5rem;
40
+ border-radius: 50%;
41
+ background: #16a34a;
42
+ margin-right: 0.4rem;
43
+ vertical-align: 0.05em;
44
+ animation: pulse 2s ease-in-out infinite;
45
+ }
46
+ .here .pulse.err { background: #dc2626; animation: none; }
47
+ @keyframes pulse {
48
+ 0%, 100% { opacity: 0.4; }
49
+ 50% { opacity: 1; }
50
+ }
13
51
 
14
52
  .ideas {
15
53
  display: flex;
16
54
  flex-direction: column;
17
55
  gap: 0.6rem;
18
- margin-bottom: 2.5rem;
56
+ margin-bottom: 1.5rem;
19
57
  }
20
58
  .idea {
21
59
  display: flex;
@@ -27,6 +65,72 @@
27
65
  .idea-text { color: var(--ink-dim); }
28
66
  .idea-text strong { color: var(--ink); }
29
67
 
68
+ /* "try it" card — folds shut by default. When opened, derives a real
69
+ secp256k1 keypair from credentials in the browser. Concrete proof
70
+ of "your identity travels with you" instead of just claiming it. */
71
+ details.try-it {
72
+ border: 1.5px dashed var(--rule);
73
+ border-radius: var(--radius);
74
+ padding: 0.6rem 0.85rem;
75
+ margin-bottom: 2rem;
76
+ }
77
+ details.try-it[open] { border-color: var(--ink); border-style: solid; }
78
+ details.try-it > summary {
79
+ cursor: pointer;
80
+ font-size: 0.85rem;
81
+ color: var(--ink-dim);
82
+ }
83
+ details.try-it[open] > summary { color: var(--ink); margin-bottom: 0.65rem; }
84
+ details.try-it > summary::before {
85
+ content: '↳ ';
86
+ color: var(--ink-dim);
87
+ }
88
+ .try-it-note {
89
+ font-size: 0.78rem;
90
+ color: var(--ink-dim);
91
+ line-height: 1.5;
92
+ margin: 0 0 0.65rem;
93
+ }
94
+ .try-it-note strong { color: var(--ink); }
95
+ .try-it-form {
96
+ display: flex;
97
+ flex-wrap: wrap;
98
+ gap: 0.5rem;
99
+ margin-bottom: 0.65rem;
100
+ }
101
+ .try-it-form input {
102
+ flex: 1 1 8rem;
103
+ font-family: monospace;
104
+ font-size: 0.85rem;
105
+ padding: 0.35rem 0.55rem;
106
+ border: 1px solid var(--rule);
107
+ border-radius: var(--radius);
108
+ background: transparent;
109
+ color: var(--ink);
110
+ }
111
+ .try-it-form input:focus {
112
+ outline: none;
113
+ border-color: var(--ink);
114
+ }
115
+ .derived-key {
116
+ font-size: 0.78rem;
117
+ color: var(--ink-dim);
118
+ padding: 0.35rem 0.55rem;
119
+ border-radius: var(--radius);
120
+ background: rgba(0, 0, 0, 0.03);
121
+ word-break: break-all;
122
+ }
123
+ .derived-key .key-label {
124
+ display: block;
125
+ margin-bottom: 0.2rem;
126
+ font-family: monospace;
127
+ }
128
+ .derived-key .key-value {
129
+ font-family: monospace;
130
+ color: var(--ink);
131
+ }
132
+ .derived-key.computing .key-value { color: var(--ink-dim); font-style: italic; }
133
+
30
134
  hr { margin: 2rem 0; }
31
135
 
32
136
  .apps-heading {
@@ -44,7 +148,9 @@
44
148
  }
45
149
 
46
150
  .app-card {
47
- display: block;
151
+ display: flex;
152
+ flex-direction: column;
153
+ gap: 0.25rem;
48
154
  text-decoration: none;
49
155
  color: var(--ink);
50
156
  border: 1.5px solid var(--ink);
@@ -56,22 +162,41 @@
56
162
  .app-card:hover { transform: translate(-1px, -1px); box-shadow: 3px 4px 0 var(--ink); }
57
163
  .app-card:active { transform: translate(1px, 2px); box-shadow: none; }
58
164
 
59
- .app-name { font-size: 1rem; margin-bottom: 0.2rem; }
165
+ .app-name-row {
166
+ display: flex;
167
+ align-items: baseline;
168
+ gap: 0.45rem;
169
+ }
170
+ .app-glyph { font-size: 1.1rem; }
171
+ .app-name { font-size: 1rem; }
60
172
  .app-desc { font-size: 0.78rem; color: var(--ink-dim); line-height: 1.4; }
61
173
 
62
174
  .footer {
175
+ display: flex;
176
+ flex-wrap: wrap;
177
+ gap: 0.5rem;
63
178
  margin-top: 3rem;
64
- font-size: 0.75rem;
179
+ }
180
+ .footer-chip {
181
+ font-size: 0.78rem;
65
182
  color: var(--ink-dim);
183
+ text-decoration: none;
184
+ padding: 0.25rem 0.6rem;
185
+ border: 1px solid var(--rule);
186
+ border-radius: var(--radius);
66
187
  }
67
- .footer a { color: var(--ink-dim); }
188
+ .footer-chip:hover { color: var(--ink); border-color: var(--ink); }
68
189
  </style>
69
190
  </head>
70
191
  <body>
71
192
 
72
- <div class="wordmark">streamo</div>
193
+ <div class="wordmark"><img src="/streamo.svg" alt="streamo">streamo</div>
73
194
  <p class="tagline">every device is an equal author</p>
74
195
 
196
+ <p class="here" id="here">
197
+ <span class="pulse" id="pulse"></span><span id="here-text">connecting…</span>
198
+ </p>
199
+
75
200
  <div class="ideas">
76
201
  <div class="idea">
77
202
  <span class="idea-glyph">↔</span>
@@ -91,26 +216,106 @@
91
216
  </div>
92
217
  </div>
93
218
 
219
+ <details class="try-it">
220
+ <summary>try it — derive a streamo identity right here</summary>
221
+ <p class="try-it-note">
222
+ type any username and password. the keypair is computed in your browser via PBKDF2-SHA256 and is <strong>never sent</strong> anywhere. same inputs always produce the same key — that's how streamo identities travel without key files. <strong>don't use a real password</strong>; this is just a demo.
223
+ </p>
224
+ <div class="try-it-form">
225
+ <input type="text" placeholder="username" id="demo-username" autocomplete="off">
226
+ <input type="password" placeholder="password" id="demo-password" autocomplete="new-password">
227
+ </div>
228
+ <div class="derived-key" id="demo-key-row">
229
+ <span class="key-label">public key (secp256k1):</span>
230
+ <code class="key-value" id="demo-key">— enter credentials above —</code>
231
+ </div>
232
+ </details>
233
+
94
234
  <hr>
95
235
 
96
236
  <p class="apps-heading">apps</p>
97
237
  <div class="app-grid">
98
238
  <a class="app-card" href="/apps/chat/">
99
- <div class="app-name">chat</div>
100
- <div class="app-desc">p2p messaging — the server is a relay, not a gatekeeper</div>
239
+ <div class="app-name-row">
240
+ <span class="app-glyph">💬</span>
241
+ <span class="app-name">chat</span>
242
+ </div>
243
+ <div class="app-desc">p2p messaging — each participant owns their own signed message stream</div>
101
244
  </a>
102
245
  <a class="app-card" href="/apps/explorer/">
103
- <div class="app-name">explorer</div>
104
- <div class="app-desc">browse repos, commit history, and value at any commit</div>
246
+ <div class="app-name-row">
247
+ <span class="app-glyph">🔍</span>
248
+ <span class="app-name">explorer</span>
249
+ </div>
250
+ <div class="app-desc">browse repos, commit history, and the bytes underneath</div>
105
251
  </a>
106
252
  </div>
107
253
  <p class="dim" style="margin-top: 1rem; font-size: 0.78rem;">
108
254
  open both side by side — watch commits roll in as you chat
109
255
  </p>
110
256
 
111
- <p class="footer">
112
- <a href="https://github.com/dtudury/streamo">github</a>
113
- </p>
257
+ <div class="footer">
258
+ <a class="footer-chip" href="https://github.com/dtudury/streamo">github</a>
259
+ <a class="footer-chip" href="https://github.com/dtudury/streamo/blob/main/design.md">design.md</a>
260
+ <a class="footer-chip" href="https://github.com/dtudury/streamo/blob/main/ROADMAP.md">roadmap</a>
261
+ <a class="footer-chip" href="https://www.npmjs.com/package/@dtudury/streamo">npm</a>
262
+ </div>
263
+
264
+ <script type="module">
265
+ import { Signer } from '/streamo/Signer.js'
266
+ import { bytesToHex } from '/streamo/utils.js'
267
+
268
+ // Surface "what's running here" — server's primary key as a clickable
269
+ // link to the explorer. Replaces the abstract "no server holds
270
+ // authority" claim above with a concrete, navigable instance of it.
271
+ const pulseEl = document.getElementById('pulse')
272
+ const hereEl = document.getElementById('here-text')
273
+ try {
274
+ const info = await fetch('/api/info').then(r => r.json())
275
+ const truncKey = info.primaryKeyHex.slice(0, 12) + '…'
276
+ hereEl.innerHTML = `relaying <a href="/apps/explorer/#/repo/${info.primaryKeyHex}">${truncKey}</a> as "${info.name || 'this server'}"`
277
+ } catch (e) {
278
+ pulseEl.classList.add('err')
279
+ hereEl.textContent = 'not connected to a streamo server'
280
+ }
281
+
282
+ // Derive-on-type for the try-it widget. Debounced because PBKDF2 is
283
+ // intentionally slow; we don't want to hammer the browser per keystroke.
284
+ const u = document.getElementById('demo-username')
285
+ const p = document.getElementById('demo-password')
286
+ const out = document.getElementById('demo-key')
287
+ const row = document.getElementById('demo-key-row')
288
+ let timer = null
289
+ let inFlight = 0
290
+ function update () {
291
+ clearTimeout(timer)
292
+ const username = u.value.trim()
293
+ const password = p.value
294
+ if (!username || !password) {
295
+ row.classList.remove('computing')
296
+ out.textContent = '— enter credentials above —'
297
+ return
298
+ }
299
+ row.classList.add('computing')
300
+ out.textContent = 'computing PBKDF2…'
301
+ const me = ++inFlight
302
+ timer = setTimeout(async () => {
303
+ try {
304
+ const signer = new Signer(username, password, 1)
305
+ const { publicKey } = await signer.keysFor('chat')
306
+ if (me !== inFlight) return // user kept typing; stale
307
+ row.classList.remove('computing')
308
+ out.textContent = '0x' + bytesToHex(publicKey)
309
+ } catch (e) {
310
+ if (me !== inFlight) return
311
+ row.classList.remove('computing')
312
+ out.textContent = `error: ${e.message}`
313
+ }
314
+ }, 300)
315
+ }
316
+ u.addEventListener('input', update)
317
+ p.addEventListener('input', update)
318
+ </script>
114
319
 
115
320
  </body>
116
321
  </html>
@@ -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
+ }
@@ -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
@@ -0,0 +1,24 @@
1
+ <svg viewBox="0 0 680 680" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
2
+ <!-- streamo mark — yin-yang seam meets basketball seam, asymmetric so the
3
+ S flows in one direction (append-only). 7 named circles intersect to
4
+ define every curve in the mark; nothing is freehand, every point is
5
+ provably-related to every other point. -->
6
+ <!-- 7 circles: Ball(340,340,r280), Yang(340,200,r140), Yin(340,480,r140), -->
7
+ <!-- Happy(397.7,240.2,r302.4), Sad(370.9,370.9,r271.6), -->
8
+ <!-- Little(394,543,r70), Big(424,292,r183.2) -->
9
+
10
+ <!-- ABJ: Ball A→B, Sad B→J, Yang J→A -->
11
+ <path d="M 340,60 A 280,280 0 0,1 582,200 A 271.6,271.6 0 0,0 447,110 A 140,140 0 0,0 340,60 Z"/>
12
+
13
+ <!-- JDKL: Yang J→D, Yin D→K, Happy K→L, Sad L→J -->
14
+ <path d="M 447,110 A 140,140 0 0,1 340,340 A 140,140 0 0,0 200,465 A 302.4,302.4 0 0,1 105,319 A 271.6,271.6 0 0,1 447,110 Z"/>
15
+
16
+ <!-- BFME: Ball B→F, Happy F→M, Little M→E, Big E→B -->
17
+ <path d="M 582,200 A 280,280 0 0,1 582,480 A 302.4,302.4 0 0,1 327,535 A 70,70 0 0,1 402,474 A 183.2,183.2 0 0,0 582,200 Z"/>
18
+
19
+ <!-- GCL: Ball G→C, Happy C→L, Sad L→G -->
20
+ <path d="M 200,582 A 280,280 0 0,1 98,200 A 302.4,302.4 0 0,0 105,319 A 271.6,271.6 0 0,0 200,582 Z"/>
21
+
22
+ <!-- HIKM: Ball H→I, Yin I→K, Happy K→M, Little M→H -->
23
+ <path d="M 412,611 A 280,280 0 0,1 340,620 A 140,140 0 0,1 200,465 A 302.4,302.4 0 0,0 327,535 A 70,70 0 0,0 412,611 Z"/>
24
+ </svg>