@dtudury/streamo 4.0.4 → 4.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -190,7 +190,7 @@ mount(h`
190
190
  `, document.body, recaller)
191
191
  ```
192
192
 
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.
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, and their descendants are reconciled in place by recursive data-key/tag matching so DOM identity, document position, scroll state, focus, and any external attachments survive on every level. Static interpolations (`${value}`) refresh to the current value on each re-render. 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.
194
194
 
195
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.
196
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtudury/streamo",
3
- "version": "4.0.4",
3
+ "version": "4.0.5",
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",
@@ -10,9 +10,17 @@
10
10
  :root { font-family: system-ui, sans-serif; font-size: 15px; --bg: #f5f5f5; --surface: #fff; --accent: #0070f3; --border: #ddd }
11
11
  body { background: var(--bg); height: 100dvh; display: flex; align-items: center; justify-content: center }
12
12
 
13
+ /* Brand lockup: clickable mark + wordmark linking home, with a
14
+ lighter page-title beside it. Same pattern in login and chat
15
+ headers so the relationship reads consistently. */
16
+ .brand-lockup { display: inline-flex; align-items: center; gap: .4rem; color: inherit; text-decoration: none; font-weight: 600 }
17
+ .brand-lockup:hover { opacity: .8 }
18
+ .page-title { font-weight: 400; color: #888; letter-spacing: .04em }
19
+ .page-title::before { content: '· '; opacity: .5 }
20
+
13
21
  /* Login */
14
22
  #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 }
15
- #login h1 { font-size: 1.2rem; font-weight: 600; display: flex; align-items: center; gap: .5rem }
23
+ #login h1 { font-size: 1.2rem; font-weight: 600; display: flex; align-items: center; gap: .4rem }
16
24
  #login h1 img { width: 1.4rem; height: 1.4rem }
17
25
  #chat-header img { width: 1.1rem; height: 1.1rem }
18
26
  #login input { border: 1px solid var(--border); border-radius: 6px; padding: .5rem .75rem; font-size: 1rem; width: 100% }
@@ -41,7 +49,10 @@
41
49
  <body>
42
50
 
43
51
  <div id="login">
44
- <h1><img src="/streamo.svg" alt="">streamo chat</h1>
52
+ <h1>
53
+ <a class="brand-lockup" href="/" title="streamo home"><img src="/streamo.svg" alt="">streamo</a>
54
+ <span class="page-title">chat</span>
55
+ </h1>
45
56
  <input id="username" placeholder="username" autocomplete="username">
46
57
  <input id="password" type="password" placeholder="password" autocomplete="current-password">
47
58
  <button id="join-btn">join</button>
@@ -50,7 +61,9 @@
50
61
 
51
62
  <div id="chat">
52
63
  <div id="chat-header">
53
- <img src="/streamo.svg" alt="">streamo chat <span id="my-name"></span>
64
+ <a class="brand-lockup" href="/" title="streamo home"><img src="/streamo.svg" alt="">streamo</a>
65
+ <span class="page-title">chat</span>
66
+ <span id="my-name"></span>
54
67
  </div>
55
68
  <div id="messages"></div>
56
69
  <div id="input-row">
@@ -18,9 +18,29 @@ const server = await StreamoServer.create({ name, username, password, dataDir, k
18
18
  console.log(`[chat] room key: ${server.publicKeyHex}`)
19
19
  console.log(`[chat] serving on http://localhost:${port}/apps/chat/`)
20
20
 
21
- if (!server.streamo.get('members')) {
22
- server.streamo.set({ ...(server.streamo.get() ?? {}), members: [] })
23
- console.log('[chat] initialized chat room')
21
+ // Seed the primary repo with chat-room bookkeeping AND the journal —
22
+ // the home repo doubles as the homepage's content source. Each future
23
+ // journal entry is a new commit on this repo, and the homepage walks
24
+ // `entries` to render. The relay link in the explorer now points
25
+ // somewhere meaningful: the journal you just read on the homepage.
26
+ {
27
+ const current = server.streamo.get() ?? {}
28
+ const seed = { ...current }
29
+ let needsCommit = false
30
+ if (!seed.members) { seed.members = []; needsCommit = true }
31
+ if (!Array.isArray(seed.entries) || seed.entries.length === 0) {
32
+ seed.entries = [{
33
+ headline: 'running streamo',
34
+ body: 'this is the streamo journal. each entry is a signed commit on this repo; the homepage walks them and the relay link in the explorer leads here. append-only history made visible.',
35
+ at: new Date()
36
+ }]
37
+ needsCommit = true
38
+ }
39
+ if (needsCommit) {
40
+ server.streamo.defaultMessage = seed.entries[seed.entries.length - 1].headline
41
+ server.streamo.set(seed)
42
+ console.log('[chat] initialized chat room + journal seed')
43
+ }
24
44
  }
25
45
 
26
46
  await server.web(port, {
@@ -7,17 +7,33 @@
7
7
  <link rel="icon" type="image/svg+xml" href="/streamo.svg">
8
8
  <link rel="stylesheet" href="/apps/styles/proto.css">
9
9
  <style>
10
+ /* scrollbar-gutter reserves the scrollbar's width whether or not it's
11
+ drawn, so a sudden scroll-needed (e.g. when the value pane grows
12
+ under hover preview) doesn't shift everything left by ~15px. */
13
+ html { scrollbar-gutter: stable; }
10
14
  body { max-width: 60rem; margin: 0 auto; padding: 2rem 1.25rem; }
11
15
 
12
- .header { display: flex; align-items: baseline; gap: 0.75rem; margin-bottom: 0.25rem; }
13
- .wordmark {
16
+ .header { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.25rem; }
17
+ /* Brand lockup: mark + wordmark, single clickable unit linking home.
18
+ Page title ("explorer") sits beside it as a separate, lighter
19
+ element so the relationship reads as [home] · [you-are-here]. */
20
+ .brand-lockup {
14
21
  display: flex;
15
22
  align-items: center;
16
23
  gap: 0.5rem;
17
24
  font-size: 1.6rem;
18
25
  letter-spacing: -0.02em;
26
+ color: var(--ink);
27
+ text-decoration: none;
28
+ }
29
+ .brand-lockup img { width: 1.8rem; height: 1.8rem; }
30
+ .brand-lockup:hover { opacity: 0.8; }
31
+ .page-title {
32
+ font-size: 0.95rem;
33
+ color: var(--ink-dim);
34
+ letter-spacing: 0.04em;
19
35
  }
20
- .wordmark img { width: 1.8rem; height: 1.8rem; }
36
+ .page-title::before { content: '· '; opacity: 0.5; }
21
37
  .crumbs { font-size: 0.85rem; color: var(--ink-dim); }
22
38
  .back { cursor: pointer; color: var(--ink-dim); font-size: 0.85rem; display: inline-block; margin-bottom: 1rem; }
23
39
  .back:hover { color: var(--ink); }
@@ -269,22 +285,15 @@
269
285
  }
270
286
  .byte-strip .sig-coverage.active { opacity: 1; }
271
287
 
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. */
288
+ /* Persistent context line for the at-view's current chunk: codec,
289
+ address, length, percentage. Quiet by default (it's permanent,
290
+ not the focus), lights up when something else on the page is
291
+ hovered to show that chunk instead. Reverts via data-default
292
+ on mouseout. */
285
293
  .chunk-inspector {
286
294
  font-family: monospace;
287
295
  font-size: 0.8rem;
296
+ color: var(--ink-dim);
288
297
  padding: 0.25rem 0.5rem;
289
298
  margin: 0.25rem 0 0.75rem;
290
299
  border-radius: var(--radius);
@@ -367,6 +376,20 @@
367
376
  }
368
377
  .tv-drill:hover { background: var(--flash); text-decoration-style: solid; }
369
378
 
379
+ /* codec-tag — colored codec name in the refs/referrers tables. Same
380
+ palette as the byte-strip and the typed-value pills, so a chunk's
381
+ codec reads visually consistent everywhere it appears. */
382
+ .codec-tag { font-weight: 500; }
383
+ .codec-tag.codec-commit { color: #c2410c; }
384
+ .codec-tag.codec-sig { color: #b91c1c; }
385
+ .codec-tag.codec-composite { color: #1e40af; }
386
+ .codec-tag.codec-duple { color: #6b21a8; }
387
+ .codec-tag.codec-string { color: #047857; }
388
+ .codec-tag.codec-bytes { color: #4d7c0f; }
389
+ .codec-tag.codec-num { color: #475569; }
390
+ .codec-tag.codec-var { color: #b45309; }
391
+ .codec-tag.codec-other { color: var(--ink-dim); }
392
+
370
393
  /* codec category palette — used in both the legend and the SVG fills */
371
394
  .cat-commit { fill: #f59e0b; background: #f59e0b; }
372
395
  .cat-sig { fill: #ef4444; background: #ef4444; }
@@ -435,8 +458,10 @@
435
458
  </head>
436
459
  <body>
437
460
  <div class="header">
438
- <div class="wordmark"><img src="/streamo.svg" alt="streamo">streamo</div>
439
- <div class="crumbs">explorer</div>
461
+ <a class="brand-lockup" href="/" title="streamo home">
462
+ <img src="/streamo.svg" alt="">streamo
463
+ </a>
464
+ <span class="page-title">explorer</span>
440
465
  </div>
441
466
  <div id="conn" class="conn">connecting…</div>
442
467
  <div id="app"></div>
@@ -58,6 +58,31 @@ function fire () {
58
58
  requestAnimationFrame(() => { stripSyncScheduled = false; syncByteStrips() })
59
59
  }
60
60
 
61
+ // Hover-only signal — separate from the bridge. Hover events that
62
+ // only set hoveredAddress fire hoverSignal exclusively; slots that
63
+ // read hoverDep() re-run, slots that don't are left alone. This is
64
+ // what keeps hovering the strip from re-rendering the strip itself.
65
+ const hoverSignal = {}
66
+ const hoverDep = () => recaller.reportKeyAccess(hoverSignal, 'data')
67
+ const hoverFire = () => recaller.reportKeyMutation(hoverSignal, 'data')
68
+
69
+ // View-shape signal — fires only when view.kind or view.keyHex
70
+ // changes. The outer mount slot watches this (NOT bridge), so
71
+ // intra-repo navigation (address changes within an at-view) does
72
+ // NOT re-run the outer slot, does NOT recreate AtView's inner slots,
73
+ // does NOT fresh-mount the byte-strip-container. Inner slots watch
74
+ // bridge — they re-run on address change, chunk arrivals, tab clicks,
75
+ // async results — and recursive-reconcile preserves the strip's DOM
76
+ // (scrollLeft, focus, keyed children) across those re-runs.
77
+ //
78
+ // Together with hoverSignal, this is the full signal decomposition:
79
+ // viewKindSignal — kind/keyHex (registry ↔ at-view, repo switch)
80
+ // bridge — chunks, address, tab, async (everything else)
81
+ // hoverSignal — strip hover preview
82
+ const viewKindSignal = {}
83
+ const viewKindDep = () => recaller.reportKeyAccess(viewKindSignal, 'data')
84
+ const viewKindFire = () => recaller.reportKeyMutation(viewKindSignal, 'data')
85
+
61
86
  // ── Hash routing ──────────────────────────────────────────────────────────
62
87
 
63
88
  function viewFromHash () {
@@ -80,15 +105,19 @@ function hashFromView (v) {
80
105
 
81
106
  let view = viewFromHash()
82
107
  function go (next) {
108
+ const kindChanged = next.kind !== view.kind || next.keyHex !== view.keyHex
83
109
  view = next
84
110
  const target = hashFromView(next)
85
111
  if (location.hash !== target) location.hash = target
112
+ if (kindChanged) viewKindFire()
86
113
  fire()
87
114
  }
88
115
  window.addEventListener('hashchange', () => {
89
116
  const next = viewFromHash()
90
117
  if (next.kind === view.kind && next.keyHex === view.keyHex && next.address === view.address) return
118
+ const kindChanged = next.kind !== view.kind || next.keyHex !== view.keyHex
91
119
  view = next
120
+ if (kindChanged) viewKindFire()
92
121
  fire()
93
122
  })
94
123
 
@@ -97,6 +126,15 @@ window.addEventListener('hashchange', () => {
97
126
  // drill-down. Reset to default on registry/repo views (set in go()).
98
127
  let atTab = 'value'
99
128
 
129
+ // Live-hover-preview state. When the user hovers a chunk on the byte
130
+ // strip, the page content below the tabs renders that chunk's value/
131
+ // storage instead of the URL's. Click to actually navigate. The header
132
+ // (selector + strip + tabs) keeps showing where you ARE; only the
133
+ // content area peeks ahead. Set by the mouseover handler when on a
134
+ // strip; cleared on mouseout. Module-level so AtView reads it inside
135
+ // the slot's reactive cell.
136
+ let hoveredAddress = null
137
+
100
138
  // ── Helpers ───────────────────────────────────────────────────────────────
101
139
 
102
140
  const truncKey = k => k.slice(0, 12) + '…'
@@ -407,78 +445,102 @@ function repoExtras (repo, keyHex) {
407
445
  `
408
446
  }
409
447
 
410
- function AtView ({ keyHex, address }) {
448
+ function AtView ({ keyHex }) {
449
+ // AtView's body is built once per repo (the outer mount slot only
450
+ // re-runs on view.kind / view.keyHex changes — see viewKindSignal).
451
+ // Anything that depends on view.address must read it fresh inside a
452
+ // reactive cell, NOT capture it in closure here. Same for resolved
453
+ // chunk lookups: each slot calls resolveContext(repo) on every run.
454
+ const resolveContext = (repo) => {
455
+ let resolvedAddr = view.address
456
+ if (view.address === 'HEAD') {
457
+ resolvedAddr = resolveHead(repo)
458
+ if (resolvedAddr === undefined) return { state: 'no-head' }
459
+ }
460
+ if (resolvedAddr >= repo.byteLength) return { state: 'loading' }
461
+ return { state: 'ok', resolvedAddr }
462
+ }
463
+
411
464
  return h`
412
465
  <a class="back" data-action="back-registry">← all repos</a>
413
466
  <div class="keyfull">
414
467
  <a class="repo-link" data-action="back-repo" data-keyhex=${keyHex}>${truncKey(keyHex)}</a>
415
- <span class="dim"> @ ${address}</span>
468
+ <span class="dim"> @ ${() => { dep(); return view.address }}</span>
416
469
  </div>
470
+
417
471
  ${() => {
472
+ // HEADER slot — bridge only. Re-runs on chunk arrivals, navigation,
473
+ // tab clicks, and async results. Does NOT re-run on hover (which
474
+ // fires hoverSignal exclusively), so hovering the strip leaves the
475
+ // selector + strip itself + tabs untouched. This is the whole
476
+ // reason the hover signal exists as a separate channel.
418
477
  dep()
419
478
  const repo = registry.get(keyHex)
420
- if (!repo) return h`<div class="empty">opening…</div>`
479
+ if (!repo) return null
480
+ const ctx = resolveContext(repo)
481
+ if (ctx.state !== 'ok') return null
482
+ const tabs = h`
483
+ <nav class="tabs">
484
+ <a class=${() => { dep(); return ['tab', atTab === 'value' ? 'active' : null] }}
485
+ data-action="set-tab" data-tab="value">value</a>
486
+ <a class=${() => { dep(); return ['tab', atTab === 'storage' ? 'active' : null] }}
487
+ data-action="set-tab" data-tab="storage">storage</a>
488
+ </nav>
489
+ `
490
+ const selector = commitSelectorSection(repo, keyHex, ctx.resolvedAddr)
491
+ const bytes = byteStreamSection(repo, keyHex, ctx.resolvedAddr)
492
+ return h`<div class="atview-header">${selector}${bytes}${tabs}</div>`
493
+ }}
421
494
 
422
- // Resolve HEAD (symbolic) to the most-recent sig address. If the
423
- // repo has no commits yet, render a useful "no HEAD" page that
424
- // still surfaces any storage chunks.
425
- let resolvedAddr = address
426
- if (address === 'HEAD') {
427
- resolvedAddr = resolveHead(repo)
428
- if (resolvedAddr === undefined) {
429
- return h`
430
- <h2>at HEAD <span class="dim">(no commits yet)</span></h2>
431
- <div class="empty">this repo doesn't have any commits yet — HEAD will resolve to the most-recent commit once one lands.</div>
432
- ${repoExtras(repo, keyHex)}
433
- `
434
- }
495
+ ${() => {
496
+ // CONTENT slot bridge + hover. Re-runs on hover (so the page
497
+ // content peeks at the hovered chunk) AND on bridge fires (for
498
+ // chunk arrivals, tab clicks, async results). Renders the tab
499
+ // body for contentAddr — the hovered address if peeking, else the
500
+ // URL's address.
501
+ dep()
502
+ hoverDep()
503
+ const repo = registry.get(keyHex)
504
+ if (!repo) return h`<div class="empty">opening…</div>`
505
+ const ctx = resolveContext(repo)
506
+ if (ctx.state === 'no-head') {
507
+ return h`
508
+ <h2>at HEAD <span class="dim">(no commits yet)</span></h2>
509
+ <div class="empty">this repo doesn't have any commits yet — HEAD will resolve to the most-recent commit once one lands.</div>
510
+ ${repoExtras(repo, keyHex)}
511
+ `
435
512
  }
436
- if (resolvedAddr >= repo.byteLength) return h`<div class="empty">loading…</div>`
513
+ if (ctx.state === 'loading') return h`<div class="empty">loading…</div>`
514
+ const resolvedAddr = ctx.resolvedAddr
515
+
516
+ // Live hover preview: contentAddr peeks at the hovered chunk;
517
+ // header still shows resolvedAddr (the URL position). Click to
518
+ // navigate.
519
+ const contentAddr = hoveredAddress != null && hoveredAddress < repo.byteLength
520
+ ? hoveredAddress
521
+ : resolvedAddr
437
522
 
438
523
  let info
439
- try { info = valueAndChildren(repo, resolvedAddr) }
524
+ try { info = valueAndChildren(repo, contentAddr) }
440
525
  catch (e) { return h`<pre class="value">decode error: ${e.message}</pre>` }
441
526
 
442
527
  const { codecType, refs, decoded } = info
443
528
  const isCommit = isCommitShape(decoded)
444
529
  const isSig = codecType === 'SIGNATURE'
445
530
 
446
- // 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.
453
- const tabs = h`
454
- <nav class="tabs">
455
- <a class=${() => { dep(); return ['tab', atTab === 'value' ? 'active' : null] }}
456
- data-action="set-tab" data-tab="value">value</a>
457
- <a class=${() => { dep(); return ['tab', atTab === 'storage' ? 'active' : null] }}
458
- data-action="set-tab" data-tab="storage">storage</a>
459
- </nav>
460
- `
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>`
466
-
467
- // Storage tab: this chunk's outgoing refs, raw bytes, and
468
- // referrers. (byteStreamSection moved up into the header.)
531
+ // Storage tab: position in stream + reachable commit, then this
532
+ // chunk's outgoing refs, raw bytes, and referrers. All for
533
+ // contentAddr (the peeked chunk during hover, otherwise the
534
+ // URL's address). Header is rendered by the sibling header slot.
469
535
  if (atTab === 'storage') {
470
536
  return h`
471
- ${header}
472
- ${outgoingReferencesSection(repo, keyHex, resolvedAddr)}
473
- ${rawChunkSection(repo, resolvedAddr)}
474
- ${referrersSection(repo, keyHex, resolvedAddr)}
537
+ ${chunkContextSection(repo, keyHex, contentAddr)}
538
+ ${outgoingReferencesSection(repo, keyHex, contentAddr)}
539
+ ${rawChunkSection(repo, contentAddr)}
540
+ ${referrersSection(repo, keyHex, contentAddr)}
475
541
  `
476
542
  }
477
543
 
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.
481
-
482
544
  // Value tab — branches by codec.
483
545
  // Helper: render the kv-table of decoded fields for any Object/Array
484
546
  // (including commits, which are just OBJECTs with a known shape).
@@ -528,7 +590,7 @@ function AtView ({ keyHex, address }) {
528
590
  // verify state from the covering sig; the verification table at the
529
591
  // bottom links to that sig and shows its bytes.
530
592
  if (isCommit) {
531
- const covering = findCoveringSig(repo, resolvedAddr)
593
+ const covering = findCoveringSig(repo, contentAddr)
532
594
  const parentDataAddr = decoded.parent !== undefined
533
595
  ? safeGet(() => repo.decode(decoded.parent)?.dataAddress)
534
596
  : undefined
@@ -551,9 +613,15 @@ function AtView ({ keyHex, address }) {
551
613
  // navigation target), so they're clickable address pills directly
552
614
  // — the chunk holding the FLOAT64 value is incidental and we
553
615
  // skip the chunk-address column for those rows.
616
+ // dataAddress and parent are byte-address pointers held as
617
+ // FLOAT64 values. The → glyph signals "this number is a
618
+ // pointer; click to follow it." Without it, the address pill
619
+ // looks like any other typedValue(number) and the navigation
620
+ // hint depends on the user already knowing what these fields
621
+ // mean.
554
622
  const addrLink = (addr) => addr === undefined
555
623
  ? 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>`
624
+ : h`<span class="dim">→ </span><a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${addr}>@${addr}</a>`
557
625
  const commitFieldsTable = h`
558
626
  <table class="kv">
559
627
  <tbody>
@@ -565,7 +633,6 @@ function AtView ({ keyHex, address }) {
565
633
  </table>
566
634
  `
567
635
  return h`
568
- ${header}
569
636
  ${banner}
570
637
  ${commitFieldsTable}
571
638
  <h3>value <span class="dim">at <a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${decoded.dataAddress}>@${decoded.dataAddress}</a></span></h3>
@@ -601,7 +668,6 @@ function AtView ({ keyHex, address }) {
601
668
  // Duple: explain what this tree-node IS, then show its two children.
602
669
  if (codecType === 'DUPLE') {
603
670
  return h`
604
- ${header}
605
671
  ${kindBanner('duple', h`<span class="dim">2-tuple, tree scaffolding</span>`)}
606
672
  <p class="explainer">
607
673
  A <strong>Duple</strong> is a 2-tuple — the building block streamo uses
@@ -629,15 +695,14 @@ function AtView ({ keyHex, address }) {
629
695
  'signature chunk',
630
696
  () => {
631
697
  dep()
632
- const status = verifyStatus(repo, keyHex, decoded, resolvedAddr)
698
+ const status = verifyStatus(repo, keyHex, decoded, contentAddr)
633
699
  return h`${verifyBadge(status)} <span class="dim">${verifyLabel(status)}</span>`
634
700
  },
635
701
  'verified'
636
702
  )
637
703
  return h`
638
- ${header}
639
704
  ${banner}
640
- ${sigDetailBody(repo, keyHex, resolvedAddr, decoded)}
705
+ ${sigDetailBody(repo, keyHex, contentAddr, decoded)}
641
706
  <div class="dim" style="margin-top: 0.5rem;">switch to the <strong>storage</strong> tab above to see the raw chunk bytes, outgoing references, and what else points at this address.</div>
642
707
  `
643
708
  }
@@ -653,7 +718,6 @@ function AtView ({ keyHex, address }) {
653
718
  ? (isArray ? 'empty array' : 'empty object')
654
719
  : (isArray ? 'array' : 'object')
655
720
  return h`
656
- ${header}
657
721
  ${kindBanner(label, dim)}
658
722
  ${refsTable()}
659
723
  ${fieldCount > 0 ? h`
@@ -665,7 +729,6 @@ function AtView ({ keyHex, address }) {
665
729
 
666
730
  // Primitive: just show it.
667
731
  return h`
668
- ${header}
669
732
  ${kindBanner(codecType.toLowerCase())}
670
733
  <pre class="value">${safeJSON(decoded)}</pre>
671
734
  `
@@ -770,6 +833,12 @@ function byteStreamSection (repo, keyHex, currentAddress) {
770
833
  return item
771
834
  })
772
835
  const stripW = cursorX
836
+ // Inspector text — codec, address, length, percentage. Defaults to
837
+ // the at-view's current chunk (currentAddress) but follows the
838
+ // hovered chunk when the user is peeking at one on the strip. The
839
+ // slot re-renders on hover (via the live-preview path), so reading
840
+ // hoveredAddress in a nested slot below — reacts to hoverDep so its
841
+ // text updates without re-rendering the strip itself.
773
842
  // Map byte address → strip x. Used by the sig-coverage overlay so hover
774
843
  // anywhere on the page can light up "what bytes does this sig sign".
775
844
  // Stored as data attrs on the strip container so the hover handler
@@ -820,9 +889,137 @@ function byteStreamSection (repo, keyHex, currentAddress) {
820
889
  })}
821
890
  <rect class="sig-coverage" x="0" y="0" width="0" height=${H} pointer-events="none"/>
822
891
  </svg>
823
- <div class="strip-direction"><span>← older</span><span>newer →</span></div>
824
892
  </div>
825
- <div class="chunk-inspector dim" data-key=${`inspector-${keyHex}`}>hover the strip to inspect a chunk</div>
893
+ ${() => {
894
+ // Inspector slot — reads hoverDep so it updates as the user moves
895
+ // across the strip, but doesn't re-render the strip itself. layout,
896
+ // total, currentAddress are closed over from byteStreamSection's
897
+ // call (which only runs on bridge fires; chunk content is fixed
898
+ // per render). isPeekActive lights up the .active background only
899
+ // when the inspector is showing something other than the URL's
900
+ // chunk.
901
+ hoverDep()
902
+ const inspectorAddr = hoveredAddress != null && hoveredAddress < total
903
+ ? hoveredAddress
904
+ : currentAddress
905
+ const inspectorChunk = layout.find(c => c.address === inspectorAddr)
906
+ const inspectorText = inspectorChunk
907
+ ? `${inspectorChunk.codecType} · @${inspectorChunk.address} · ${inspectorChunk.length} bytes${total > 0 ? ` (${((inspectorChunk.length / total) * 100).toFixed(2)}% of ${total})` : ''}`
908
+ : `${chunks.length} chunks · ${total} bytes`
909
+ const isPeekActive = hoveredAddress != null && hoveredAddress !== currentAddress
910
+ return h`<div class=${['chunk-inspector', isPeekActive ? 'active' : null]}
911
+ data-key=${`inspector-${keyHex}`}>${inspectorText}</div>`
912
+ }}
913
+ `
914
+ }
915
+
916
+ // Commit reachability — the *semantic* parent of every chunk. Commits
917
+ // don't reference their data tree via asRefs; they store the address
918
+ // as a number value (a FLOAT64), and the convention "follow this
919
+ // number to find your data" is implicit. So the structural referrer
920
+ // index doesn't connect chunks to their owning commits. This walk
921
+ // makes the connection: for each commit, BFS from commit.dataAddress
922
+ // through asRefs and mark every reachable chunk. Result: chunk
923
+ // address → set of commit addresses whose dataAddress reach it.
924
+ function buildCommitReachabilityIndex (repo) {
925
+ const reach = new Map() // chunk addr → Set<commit addr>
926
+ let walk = repo.byteLength - 1
927
+ while (walk >= 0) {
928
+ const code = repo.resolve(walk)
929
+ if (!code || !code.length) break
930
+ const type = repo.footerToCodec[code.at(-1)]?.type
931
+ if (type === 'OBJECT') {
932
+ let value
933
+ try { value = repo.decode(walk) } catch {}
934
+ if (value && isCommitShape(value)) {
935
+ const visited = new Set()
936
+ const stack = [value.dataAddress]
937
+ while (stack.length) {
938
+ const a = stack.pop()
939
+ if (typeof a !== 'number' || visited.has(a)) continue
940
+ visited.add(a)
941
+ if (!reach.has(a)) reach.set(a, new Set())
942
+ reach.get(a).add(walk)
943
+ let refs
944
+ try { refs = repo.asRefs(a) } catch {}
945
+ if (Array.isArray(refs)) {
946
+ for (const c of refs) if (typeof c === 'number') stack.push(c)
947
+ } else if (refs && typeof refs === 'object' && !(refs instanceof Date) && !(refs instanceof Uint8Array)) {
948
+ if (Array.isArray(refs.v)) {
949
+ for (const c of refs.v) if (typeof c === 'number') stack.push(c)
950
+ } else {
951
+ for (const c of Object.values(refs)) if (typeof c === 'number') stack.push(c)
952
+ }
953
+ }
954
+ }
955
+ }
956
+ }
957
+ walk -= code.length
958
+ }
959
+ return reach
960
+ }
961
+
962
+ // Storage-tab context: which commits reach this chunk. (Position info
963
+ // — byte range, percentage, codec — now lives in the persistent chunk
964
+ // inspector under the byte strip, so this section focuses on the
965
+ // "story" of the chunk's place in user data.) Uses the commit-
966
+ // reachability index above, falling back to the structural referrer
967
+ // BFS for chunks not reachable from any commit (typically sigs and
968
+ // other top-level chunks).
969
+ function chunkContextSection (repo, keyHex, address) {
970
+ const reach = buildCommitReachabilityIndex(repo)
971
+ const reachingCommits = reach.get(address)
972
+ let label = null
973
+ if (reachingCommits && reachingCommits.size) {
974
+ // Show the most recent commit (highest address) reaching this chunk,
975
+ // plus a count if there are more.
976
+ const sorted = [...reachingCommits].sort((a, b) => b - a)
977
+ const newest = sorted[0]
978
+ label = h`
979
+ <a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${newest}>@${newest}</a>
980
+ ${sorted.length > 1
981
+ ? h` <span class="dim">(and ${sorted.length - 1} earlier commit${sorted.length === 2 ? '' : 's'})</span>`
982
+ : null}
983
+ `
984
+ } else {
985
+ // Fall back to the structural BFS — catches sig chunks (referenced
986
+ // by nothing in the asRefs sense, but the user might want to know
987
+ // some commit they cover).
988
+ const index = buildReferrerIndex(repo)
989
+ const visited = new Set()
990
+ let frontier = [address]
991
+ let depth = 0
992
+ while (frontier.length && depth < 64 && !label) {
993
+ const next = []
994
+ for (const a of frontier) {
995
+ if (visited.has(a)) continue
996
+ visited.add(a)
997
+ const parents = index.get(a) ?? []
998
+ for (const p of parents) {
999
+ try {
1000
+ const decoded = repo.decode(p.address)
1001
+ if (isCommitShape(decoded)) {
1002
+ label = h`<a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${p.address}>@${p.address}</a> <span class="dim">(via structural ref)</span>`
1003
+ break
1004
+ }
1005
+ } catch {}
1006
+ next.push(p.address)
1007
+ }
1008
+ if (label) break
1009
+ }
1010
+ frontier = next
1011
+ depth++
1012
+ }
1013
+ }
1014
+ if (!label) {
1015
+ label = h`<span class="dim">no commit references this chunk</span>`
1016
+ }
1017
+ return h`
1018
+ <h3>reachable from <span class="dim">user-meaningful commits</span></h3>
1019
+ <p class="dim" style="font-size: 0.85rem; margin-bottom: 0.4rem;">
1020
+ commits hold their data's location as a number-valued <code>dataAddress</code> field, so reachability isn't a structural ref — it's "starting from each commit's data, this chunk shows up."
1021
+ </p>
1022
+ <p>${label}</p>
826
1023
  `
827
1024
  }
828
1025
 
@@ -830,7 +1027,8 @@ function byteStreamSection (repo, keyHex, currentAddress) {
830
1027
  // opposed to "referenced by", which is what points to this chunk). Walks
831
1028
  // the codec's parts via repo.directReferences. Codec-by-codec — exposes
832
1029
  // the storage chain so e.g. STRING → UINT8ARRAY → DUPLE → DUPLE → … → WORD
833
- // is browsable one click at a time.
1030
+ // is browsable one click at a time. Codec column is color-coded to match
1031
+ // the byte-strip palette so the chain reads visually.
834
1032
  function outgoingReferencesSection (repo, keyHex, address) {
835
1033
  const refs = repo.directReferences(address)
836
1034
  if (!refs.length) return null
@@ -849,7 +1047,7 @@ function outgoingReferencesSection (repo, keyHex, address) {
849
1047
  return h`
850
1048
  <tr data-key=${`out${i}@${childAddr}`} data-action="open-at"
851
1049
  data-keyhex=${keyHex} data-addr=${childAddr}>
852
- <td class="mono dim">${codecType}</td>
1050
+ <td class=${['mono', 'codec-tag', `codec-${codecCategory(codecType)}`]}>${codecType}</td>
853
1051
  <td>${preview}</td>
854
1052
  <td class="mono dim">@${childAddr}</td>
855
1053
  </tr>
@@ -887,7 +1085,7 @@ function referrersSection (repo, keyHex, address) {
887
1085
  return h`
888
1086
  <tr data-key=${`r${r.address}`} data-action="open-at"
889
1087
  data-keyhex=${keyHex} data-addr=${r.address}>
890
- <td class="mono dim">${r.codecType || '?'}${r.count > 1 ? ` ×${r.count}` : ''}</td>
1088
+ <td class=${['mono', 'codec-tag', `codec-${codecCategory(r.codecType || '?')}`]}>${r.codecType || '?'}${r.count > 1 ? ` ×${r.count}` : ''}</td>
891
1089
  <td>${preview}</td>
892
1090
  <td class="mono dim">@${r.address}</td>
893
1091
  </tr>
@@ -1143,17 +1341,24 @@ function hexDump (bytes, maxLen = 256) {
1143
1341
 
1144
1342
  const appEl = document.getElementById('app')
1145
1343
 
1146
- // Wrap each view in a data-keyed <section> so mount's tag-pool recycling
1147
- // doesn't pull stale elements from one view into another. Without this,
1148
- // switching from registry to an at-view would recycle the registry's
1149
- // <h2> and keep its old text children (patchElement only updates attrs).
1150
- // The data-key changes whenever the view's identity changes (kind + the
1151
- // params that affect rendering), forcing a fresh mount.
1344
+ // Outer mount slot. Reads viewKindDep ONLY re-runs on view.kind
1345
+ // or view.keyHex changes (registry at, or switching repos). It does
1346
+ // NOT re-run on address changes, chunk arrivals, tab clicks, or any
1347
+ // other bridge fire. That's the whole point of the decomposition:
1348
+ // keep the at-view's <section> (and the strip-container inside it)
1349
+ // alive across intra-repo navigation so click-to-navigate doesn't
1350
+ // rebuild the strip and reset its scrollLeft.
1351
+ //
1352
+ // Each view gets a data-keyed <section> so mount's matcher distinguishes
1353
+ // them — switching from registry to an at-view, or between repos, drops
1354
+ // the old section and fresh-mounts the new one. RegistryView and AtView
1355
+ // each do their own internal reactivity (inner slots reading dep() and
1356
+ // hoverDep()) for everything within a view.
1152
1357
  mount(h`${() => {
1153
- dep()
1358
+ viewKindDep()
1154
1359
  switch (view.kind) {
1155
1360
  case 'registry': return h`<section class="view" data-key="view-registry">${RegistryView()}</section>`
1156
- case 'at': return h`<section class="view" data-key=${`view-at-${view.keyHex}-${view.address}`}>${AtView({ keyHex: view.keyHex, address: view.address })}</section>`
1361
+ case 'at': return h`<section class="view" data-key=${`view-at-${view.keyHex}`}>${AtView({ keyHex: view.keyHex })}</section>`
1157
1362
  default: return h`<div class="empty">?</div>`
1158
1363
  }
1159
1364
  }}`, appEl, recaller)
@@ -1206,8 +1411,12 @@ function syncByteStrips () {
1206
1411
  for (const container of appEl.querySelectorAll('.byte-strip-container')) {
1207
1412
  const visible = container.clientWidth || 1
1208
1413
  const atRight = container.scrollLeft + visible >= container.scrollWidth - 8
1209
- if (!container.dataset.pinned || atRight) {
1210
- container.dataset.pinned = '1'
1414
+ // Auto-pin to HEAD only when the strip is freshly mounted (scroll
1415
+ // hasn't been touched yet, so scrollLeft === 0). For recycled strips
1416
+ // mount restores the previous scrollLeft, so this branch correctly
1417
+ // skips and the user's position is preserved. Live updates still
1418
+ // pin if the user is already at the right edge (atRight).
1419
+ if (container.scrollLeft === 0 || atRight) {
1211
1420
  container.scrollLeft = container.scrollWidth
1212
1421
  }
1213
1422
  }
@@ -1272,17 +1481,10 @@ appEl.addEventListener('mouseover', e => {
1272
1481
  }
1273
1482
  })
1274
1483
  }
1275
- // Look up the chunk's data on the strip rect for inspector + sig coverage.
1484
+ // Look up the chunk's data on the strip rect for sig-coverage overlay.
1276
1485
  const stripRect = matches[0]?.closest('.byte-strip-container') ? matches[0]
1277
1486
  : appEl.querySelector(`.byte-strip .chunk[data-addr="${addr}"]`)
1278
1487
  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
1488
  const fromX = stripRect.getAttribute('data-sig-from-x')
1287
1489
  const toX = stripRect.getAttribute('data-sig-to-x')
1288
1490
  if (fromX != null && toX != null) {
@@ -1294,15 +1496,31 @@ appEl.addEventListener('mouseover', e => {
1294
1496
  }
1295
1497
  }
1296
1498
  }
1499
+ // Live preview: if the hovered chunk is on the byte strip, set
1500
+ // hoveredAddress so the page content below renders for that chunk.
1501
+ // Click to actually navigate. Only fire if the address changed —
1502
+ // moving within the same chunk shouldn't re-render.
1503
+ const onStrip = el.closest('.byte-strip-container')
1504
+ const newHover = onStrip ? +addr : null
1505
+ if (newHover !== hoveredAddress) {
1506
+ hoveredAddress = newHover
1507
+ hoverFire()
1508
+ }
1297
1509
  })
1298
1510
  appEl.addEventListener('mouseout', e => {
1299
1511
  const el = e.target.closest('[data-addr]')
1300
1512
  if (!el) return
1301
1513
  appEl.querySelectorAll('.byte-map .chunk.hovered').forEach(c => c.classList.remove('hovered'))
1302
1514
  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')
1515
+ // Clear hoveredAddress unless the cursor is moving to ANOTHER chunk
1516
+ // on the strip. The previous check ("still inside .byte-strip-container")
1517
+ // treated the direction labels and any blank-space as "still hovering,"
1518
+ // which left the page stuck on the previously hovered chunk's content.
1519
+ // Requiring .chunk[data-addr] specifically means moving off a chunk
1520
+ // anywhere — out of the strip OR to its non-chunk regions — reverts.
1521
+ const goingToChunk = e.relatedTarget?.closest?.('.byte-strip-container .chunk[data-addr]')
1522
+ if (!goingToChunk && hoveredAddress !== null) {
1523
+ hoveredAddress = null
1524
+ hoverFire()
1307
1525
  }
1308
1526
  })
package/public/index.html CHANGED
@@ -7,16 +7,20 @@
7
7
  <link rel="icon" type="image/svg+xml" href="/streamo.svg">
8
8
  <link rel="stylesheet" href="/apps/styles/proto.css">
9
9
  <style>
10
+ html { scrollbar-gutter: stable; }
10
11
  body { max-width: 44rem; margin: 0 auto; padding: 2.5rem 1.25rem; }
11
12
 
12
13
  .wordmark {
13
- display: flex;
14
+ display: inline-flex;
14
15
  align-items: center;
15
16
  gap: 0.6rem;
16
17
  font-size: 2.4rem;
17
18
  letter-spacing: -0.02em;
18
19
  margin-bottom: 0.15rem;
20
+ text-decoration: none;
21
+ color: inherit;
19
22
  }
23
+ .wordmark:hover { opacity: 0.85; }
20
24
  .wordmark img { width: 2.6rem; height: 2.6rem; }
21
25
  .tagline { color: var(--ink-dim); font-size: 0.95rem; margin-bottom: 0.4rem; }
22
26
 
@@ -171,6 +175,46 @@
171
175
  .app-name { font-size: 1rem; }
172
176
  .app-desc { font-size: 0.78rem; color: var(--ink-dim); line-height: 1.4; }
173
177
 
178
+ /* Journal — the home repo's `entries` array, rendered live. Each entry
179
+ is a signed commit; click "see all" to land on the relay'd repo
180
+ in the explorer. The homepage demonstrates streamo by being made
181
+ of it. */
182
+ .journal-heading {
183
+ font-size: 0.7rem;
184
+ text-transform: uppercase;
185
+ letter-spacing: 0.1em;
186
+ color: var(--ink-dim);
187
+ margin: 2.5rem 0 0.75rem;
188
+ }
189
+ .journal-entry {
190
+ padding: 0.75rem 0;
191
+ border-top: 1px solid var(--rule);
192
+ }
193
+ .journal-entry:last-child { border-bottom: 1px solid var(--rule); }
194
+ .journal-meta {
195
+ font-size: 0.7rem;
196
+ font-family: monospace;
197
+ margin-bottom: 0.25rem;
198
+ }
199
+ .journal-headline {
200
+ font-size: 0.95rem;
201
+ font-weight: 600;
202
+ margin-bottom: 0.3rem;
203
+ }
204
+ .journal-body {
205
+ font-size: 0.85rem;
206
+ line-height: 1.5;
207
+ color: var(--ink-dim);
208
+ }
209
+ .journal-more {
210
+ font-size: 0.78rem;
211
+ color: var(--ink-dim);
212
+ margin-top: 0.6rem;
213
+ display: inline-block;
214
+ text-decoration: underline dotted;
215
+ }
216
+ .journal-more:hover { color: var(--ink); text-decoration-style: solid; }
217
+
174
218
  .footer {
175
219
  display: flex;
176
220
  flex-wrap: wrap;
@@ -190,7 +234,7 @@
190
234
  </head>
191
235
  <body>
192
236
 
193
- <div class="wordmark"><img src="/streamo.svg" alt="streamo">streamo</div>
237
+ <a class="wordmark" href="/" title="streamo home"><img src="/streamo.svg" alt="">streamo</a>
194
238
  <p class="tagline">every device is an equal author</p>
195
239
 
196
240
  <p class="here" id="here">
@@ -231,6 +275,12 @@
231
275
  </div>
232
276
  </details>
233
277
 
278
+ <p class="journal-heading">journal</p>
279
+ <div id="journal-entries">
280
+ <p class="dim" style="font-size: 0.85rem;">loading…</p>
281
+ </div>
282
+ <a id="journal-more" class="journal-more" style="display:none">see all entries in the explorer →</a>
283
+
234
284
  <hr>
235
285
 
236
286
  <p class="apps-heading">apps</p>
@@ -264,14 +314,21 @@
264
314
  <script type="module">
265
315
  import { Signer } from '/streamo/Signer.js'
266
316
  import { bytesToHex } from '/streamo/utils.js'
317
+ import { Recaller } from '/streamo/utils/Recaller.js'
318
+ import { RepoRegistry } from '/streamo/RepoRegistry.js'
319
+ import { registrySync } from '/streamo/registrySync.js'
320
+ import { bridgeRegistry } from '/streamo/bridgeRegistry.js'
321
+ import { h } from '/streamo/h.js'
322
+ import { mount } from '/streamo/mount.js'
267
323
 
268
324
  // Surface "what's running here" — server's primary key as a clickable
269
325
  // link to the explorer. Replaces the abstract "no server holds
270
326
  // authority" claim above with a concrete, navigable instance of it.
271
327
  const pulseEl = document.getElementById('pulse')
272
328
  const hereEl = document.getElementById('here-text')
329
+ let info = null
273
330
  try {
274
- const info = await fetch('/api/info').then(r => r.json())
331
+ info = await fetch('/api/info').then(r => r.json())
275
332
  const truncKey = info.primaryKeyHex.slice(0, 12) + '…'
276
333
  hereEl.innerHTML = `relaying <a href="/apps/explorer/#/repo/${info.primaryKeyHex}">${truncKey}</a> as "${info.name || 'this server'}"`
277
334
  } catch (e) {
@@ -279,6 +336,58 @@
279
336
  hereEl.textContent = 'not connected to a streamo server'
280
337
  }
281
338
 
339
+ // Subscribe to the home repo and render its journal entries. The
340
+ // homepage IS made of streamo: each entry is a signed commit on
341
+ // the home repo, walked here at render time. Live — new entries
342
+ // appear without refresh.
343
+ if (info) {
344
+ try {
345
+ const registry = new RepoRegistry()
346
+ await registrySync(registry, location.hostname, +location.port || 80, {
347
+ filter: key => key === info.primaryKeyHex
348
+ })
349
+ const recaller = new Recaller('home')
350
+ const { dep } = bridgeRegistry(registry, recaller, 'home')
351
+ const entriesEl = document.getElementById('journal-entries')
352
+ const moreEl = document.getElementById('journal-more')
353
+ moreEl.href = `/apps/explorer/#/repo/${info.primaryKeyHex}`
354
+ const fmtDate = d => {
355
+ const date = d instanceof Date ? d : new Date(d)
356
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
357
+ }
358
+ // Replace the "loading…" placeholder with our reactive cell.
359
+ entriesEl.innerHTML = ''
360
+ mount(h`${() => {
361
+ dep()
362
+ const repo = registry.get(info.primaryKeyHex)
363
+ if (!repo) return h`<p class="dim" style="font-size: 0.85rem;">connecting…</p>`
364
+ const entries = repo.get('entries') ?? []
365
+ if (entries.length === 0) {
366
+ return h`<p class="dim" style="font-size: 0.85rem;">no entries yet — the deployer hasn't written anything to this repo.</p>`
367
+ }
368
+ // Newest-first; show up to 5.
369
+ const latest = entries.slice(-5).reverse()
370
+ if (entries.length > latest.length) {
371
+ moreEl.style.display = 'inline-block'
372
+ moreEl.textContent = `see all ${entries.length} entries in the explorer →`
373
+ } else {
374
+ moreEl.style.display = 'inline-block'
375
+ moreEl.textContent = `view in the explorer →`
376
+ }
377
+ return latest.map(e => h`
378
+ <div class="journal-entry">
379
+ <div class="journal-meta dim">${e.at != null ? fmtDate(e.at) : ''}</div>
380
+ <div class="journal-headline">${e.headline ?? '(no headline)'}</div>
381
+ <div class="journal-body">${e.body ?? ''}</div>
382
+ </div>
383
+ `)
384
+ }}`, entriesEl, recaller)
385
+ } catch (e) {
386
+ document.getElementById('journal-entries').innerHTML =
387
+ `<p class="dim" style="font-size: 0.85rem;">journal unavailable (${e.message})</p>`
388
+ }
389
+ }
390
+
282
391
  // Derive-on-type for the try-it widget. Debounced because PBKDF2 is
283
392
  // intentionally slow; we don't want to hammer the browser per keystroke.
284
393
  const u = document.getElementById('demo-username')
@@ -192,30 +192,17 @@ function reconcileSlot (start, end, newVNodes, recaller, ns = HTML_NS) {
192
192
  old.remove()
193
193
  }
194
194
 
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.
195
+ // Reinsert recycled elements (recursively reconciled) and mount fresh
196
+ // ones, in order. Recursive reconcile preserves descendant DOM and
197
+ // watchers only attrs of the matched element are reset, children
198
+ // that match by data-key/tag are themselves reconciled in place. This
199
+ // is what lets a deeply nested data-keyed element (e.g. the byte
200
+ // strip's container) survive an outer-slot re-render with its
201
+ // scrollLeft, focus, and inner slot state intact.
206
202
  for (const vnode of newVNodes) {
207
203
  const recycled = vnodeToEl.get(vnode)
208
204
  if (recycled) {
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)
205
+ reconcileElement(recycled, vnode, recaller, ns)
219
206
  end.before(recycled)
220
207
  } else {
221
208
  const frag = document.createDocumentFragment()
@@ -225,6 +212,133 @@ function reconcileSlot (start, end, newVNodes, recaller, ns = HTML_NS) {
225
212
  }
226
213
  }
227
214
 
215
+ // ── Recursive reconcile ──────────────────────────────────────────────────
216
+ //
217
+ // reconcileElement updates a matched element's attributes and recursively
218
+ // reconciles its children — preserving descendant DOM (and any browser
219
+ // state on it: scrollLeft, focus, scroll positions, inner slot anchors)
220
+ // when the new vnode tree's structure agrees with the existing one.
221
+ //
222
+ // The element's OWN attr watchers are unwatched and re-applied (their
223
+ // closures may have changed across the outer render). Descendant
224
+ // watchers are NOT touched — only re-applied where their containing
225
+ // element gets reconciled itself, deeper in the recursion.
226
+ //
227
+ // Children that don't match a new vnode (by data-key for keyed elements,
228
+ // by tag-pool for unkeyed) are cleaned up and removed; new vnodes that
229
+ // don't match an existing child are fresh-mounted.
230
+
231
+ function reconcileElement (el, vnode, recaller, ns) {
232
+ // Determine child namespace, mirroring mountNode
233
+ const nsAttr = vnode.attrs.find(a => a?.name === 'xmlns')?.value
234
+ const elemNs = nsAttr
235
+ ?? (vnode.tag === 'svg' ? SVG_NS
236
+ : vnode.tag === 'foreignObject' ? HTML_NS
237
+ : ns)
238
+ // Snapshot scrollLeft/scrollTop so a hypothetical reflow during
239
+ // child reconcile doesn't lose the user's scroll position.
240
+ const scrollLeft = el.scrollLeft
241
+ const scrollTop = el.scrollTop
242
+ reconcileAttrs(el, vnode, recaller)
243
+ reconcileChildren(el, vnode.children, recaller, elemNs)
244
+ el.scrollLeft = scrollLeft
245
+ el.scrollTop = scrollTop
246
+ }
247
+
248
+ function reconcileAttrs (el, vnode, recaller) {
249
+ // Cleanup el's OWN attr watchers — descendants' watchers are NOT
250
+ // touched (they belong to elements that may themselves be matched
251
+ // and reconciled deeper in the recursion).
252
+ const fns = nodeCleanups.get(el)
253
+ if (fns) {
254
+ for (const f of fns) recaller.unwatch(f)
255
+ nodeCleanups.delete(el)
256
+ }
257
+ const oldAttrNames = Array.from(el.attributes, a => a.name)
258
+ for (const name of oldAttrNames) el.removeAttribute(name)
259
+ for (const attr of vnode.attrs) {
260
+ if (attr == null) continue
261
+ applyAttr(el, attr, recaller)
262
+ }
263
+ }
264
+
265
+ function reconcileChildren (parent, vnodeChildren, recaller, ns) {
266
+ // Flatten arrays/null in the vnode list so positional walking is clean.
267
+ const flat = []
268
+ const flatten = (v) => {
269
+ if (v == null) return
270
+ if (Array.isArray(v)) v.forEach(flatten)
271
+ else flat.push(v)
272
+ }
273
+ vnodeChildren.forEach(flatten)
274
+
275
+ // Collect existing element children (only elements are recyclable —
276
+ // text nodes, comments, and slot anchors get cleaned up + rebuilt).
277
+ const existingEls = []
278
+ for (const child of parent.childNodes) {
279
+ if (child.nodeType === Node.ELEMENT_NODE) existingEls.push(child)
280
+ }
281
+
282
+ // Same matching strategy as reconcileSlot: keyed-by-data-key first,
283
+ // unkeyed by tag pool.
284
+ const keyedMap = new Map()
285
+ const tagPool = new Map()
286
+ for (const el of existingEls) {
287
+ const key = el.getAttribute('data-key')
288
+ if (key != null) {
289
+ keyedMap.set(key, el)
290
+ } else {
291
+ const tag = el.tagName.toLowerCase()
292
+ if (!tagPool.has(tag)) tagPool.set(tag, [])
293
+ tagPool.get(tag).push(el)
294
+ }
295
+ }
296
+
297
+ const recycledEls = new Set()
298
+ const vnodeToEl = new Map()
299
+ for (const vnode of flat) {
300
+ if (!(vnode instanceof HElement)) continue
301
+ if (typeof vnode.tag === 'function') continue // function components mount fresh
302
+ const keyAttr = vnode.attrs.find(a => a?.name === 'data-key')
303
+ const keyVal = keyAttr?.value
304
+ const key = (keyVal != null && typeof keyVal !== 'function' && !Array.isArray(keyVal))
305
+ ? String(keyVal) : null
306
+ let el = null
307
+ if (key != null) {
308
+ const candidate = keyedMap.get(key)
309
+ if (candidate && !recycledEls.has(candidate)) el = candidate
310
+ } else {
311
+ const pool = tagPool.get(vnode.tag)
312
+ if (pool) el = pool.find(e => !recycledEls.has(e)) ?? null
313
+ }
314
+ if (el) {
315
+ recycledEls.add(el)
316
+ vnodeToEl.set(vnode, el)
317
+ }
318
+ }
319
+
320
+ // Detach recycled, cleanup + remove the rest (text nodes, comments,
321
+ // unmatched elements all go through cleanupNode so any watchers in
322
+ // their subtrees are released).
323
+ for (const el of recycledEls) el.remove()
324
+ while (parent.firstChild) {
325
+ const old = parent.firstChild
326
+ cleanupNode(old, recaller)
327
+ old.remove()
328
+ }
329
+
330
+ // Insert in order: recursively reconcile recycled, mount fresh otherwise.
331
+ for (const vnode of flat) {
332
+ const recycled = vnodeToEl.get(vnode)
333
+ if (recycled) {
334
+ reconcileElement(recycled, vnode, recaller, ns)
335
+ parent.appendChild(recycled)
336
+ } else {
337
+ mountNode(vnode, parent, recaller, ns)
338
+ }
339
+ }
340
+ }
341
+
228
342
  // ── Function components ───────────────────────────────────────────────────
229
343
  //
230
344
  // When an HElement's tag is a function, call it with a props object instead
@@ -1,4 +1,4 @@
1
- <svg viewBox="0 0 680 680" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
1
+ <svg viewBox="0 0 680 680" xmlns="http://www.w3.org/2000/svg" fill="#1d4ed8">
2
2
  <!-- streamo mark — yin-yang seam meets basketball seam, asymmetric so the
3
3
  S flows in one direction (append-only). 7 named circles intersect to
4
4
  define every curve in the mark; nothing is freehand, every point is