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