@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 +1 -1
- package/package.json +1 -1
- package/public/apps/chat/index.html +16 -3
- package/public/apps/chat/server.js +23 -3
- package/public/apps/explorer/index.html +43 -18
- package/public/apps/explorer/main.js +303 -85
- package/public/index.html +112 -3
- package/public/streamo/mount.js +135 -21
- package/public/streamo.svg +1 -1
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
|
|
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.
|
|
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: .
|
|
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
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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:
|
|
13
|
-
|
|
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
|
-
.
|
|
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
|
-
/*
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
<
|
|
439
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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 (
|
|
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,
|
|
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
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
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
|
-
${
|
|
472
|
-
${outgoingReferencesSection(repo, keyHex,
|
|
473
|
-
${rawChunkSection(repo,
|
|
474
|
-
${referrersSection(repo, keyHex,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
1210
|
-
|
|
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
|
|
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
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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')
|
package/public/streamo/mount.js
CHANGED
|
@@ -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
|
|
196
|
-
//
|
|
197
|
-
// watchers
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
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
|
-
|
|
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
|
package/public/streamo.svg
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<svg viewBox="0 0 680 680" xmlns="http://www.w3.org/2000/svg" fill="
|
|
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
|