@dtudury/streamo 4.0.2 → 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 +5 -1
- package/package.json +1 -1
- package/public/apps/chat/index.html +19 -3
- package/public/apps/chat/server.js +23 -3
- package/public/apps/explorer/index.html +121 -5
- package/public/apps/explorer/main.js +469 -96
- package/public/index.html +329 -15
- package/public/streamo/mount.js +135 -21
- package/public/streamo.svg +24 -0
package/public/index.html
CHANGED
|
@@ -4,18 +4,60 @@
|
|
|
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>
|
|
10
|
+
html { scrollbar-gutter: stable; }
|
|
9
11
|
body { max-width: 44rem; margin: 0 auto; padding: 2.5rem 1.25rem; }
|
|
10
12
|
|
|
11
|
-
.wordmark {
|
|
12
|
-
|
|
13
|
+
.wordmark {
|
|
14
|
+
display: inline-flex;
|
|
15
|
+
align-items: center;
|
|
16
|
+
gap: 0.6rem;
|
|
17
|
+
font-size: 2.4rem;
|
|
18
|
+
letter-spacing: -0.02em;
|
|
19
|
+
margin-bottom: 0.15rem;
|
|
20
|
+
text-decoration: none;
|
|
21
|
+
color: inherit;
|
|
22
|
+
}
|
|
23
|
+
.wordmark:hover { opacity: 0.85; }
|
|
24
|
+
.wordmark img { width: 2.6rem; height: 2.6rem; }
|
|
25
|
+
.tagline { color: var(--ink-dim); font-size: 0.95rem; margin-bottom: 0.4rem; }
|
|
26
|
+
|
|
27
|
+
/* "what's running here" line — small, clickable, places the abstract
|
|
28
|
+
claims in the next paragraph onto a real running server. */
|
|
29
|
+
.here {
|
|
30
|
+
font-size: 0.78rem;
|
|
31
|
+
color: var(--ink-dim);
|
|
32
|
+
margin-bottom: 2rem;
|
|
33
|
+
font-family: monospace;
|
|
34
|
+
}
|
|
35
|
+
.here a {
|
|
36
|
+
color: var(--ink-dim);
|
|
37
|
+
text-decoration: underline dotted;
|
|
38
|
+
}
|
|
39
|
+
.here a:hover { color: var(--ink); text-decoration-style: solid; }
|
|
40
|
+
.here .pulse {
|
|
41
|
+
display: inline-block;
|
|
42
|
+
width: 0.5rem;
|
|
43
|
+
height: 0.5rem;
|
|
44
|
+
border-radius: 50%;
|
|
45
|
+
background: #16a34a;
|
|
46
|
+
margin-right: 0.4rem;
|
|
47
|
+
vertical-align: 0.05em;
|
|
48
|
+
animation: pulse 2s ease-in-out infinite;
|
|
49
|
+
}
|
|
50
|
+
.here .pulse.err { background: #dc2626; animation: none; }
|
|
51
|
+
@keyframes pulse {
|
|
52
|
+
0%, 100% { opacity: 0.4; }
|
|
53
|
+
50% { opacity: 1; }
|
|
54
|
+
}
|
|
13
55
|
|
|
14
56
|
.ideas {
|
|
15
57
|
display: flex;
|
|
16
58
|
flex-direction: column;
|
|
17
59
|
gap: 0.6rem;
|
|
18
|
-
margin-bottom:
|
|
60
|
+
margin-bottom: 1.5rem;
|
|
19
61
|
}
|
|
20
62
|
.idea {
|
|
21
63
|
display: flex;
|
|
@@ -27,6 +69,72 @@
|
|
|
27
69
|
.idea-text { color: var(--ink-dim); }
|
|
28
70
|
.idea-text strong { color: var(--ink); }
|
|
29
71
|
|
|
72
|
+
/* "try it" card — folds shut by default. When opened, derives a real
|
|
73
|
+
secp256k1 keypair from credentials in the browser. Concrete proof
|
|
74
|
+
of "your identity travels with you" instead of just claiming it. */
|
|
75
|
+
details.try-it {
|
|
76
|
+
border: 1.5px dashed var(--rule);
|
|
77
|
+
border-radius: var(--radius);
|
|
78
|
+
padding: 0.6rem 0.85rem;
|
|
79
|
+
margin-bottom: 2rem;
|
|
80
|
+
}
|
|
81
|
+
details.try-it[open] { border-color: var(--ink); border-style: solid; }
|
|
82
|
+
details.try-it > summary {
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
font-size: 0.85rem;
|
|
85
|
+
color: var(--ink-dim);
|
|
86
|
+
}
|
|
87
|
+
details.try-it[open] > summary { color: var(--ink); margin-bottom: 0.65rem; }
|
|
88
|
+
details.try-it > summary::before {
|
|
89
|
+
content: '↳ ';
|
|
90
|
+
color: var(--ink-dim);
|
|
91
|
+
}
|
|
92
|
+
.try-it-note {
|
|
93
|
+
font-size: 0.78rem;
|
|
94
|
+
color: var(--ink-dim);
|
|
95
|
+
line-height: 1.5;
|
|
96
|
+
margin: 0 0 0.65rem;
|
|
97
|
+
}
|
|
98
|
+
.try-it-note strong { color: var(--ink); }
|
|
99
|
+
.try-it-form {
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-wrap: wrap;
|
|
102
|
+
gap: 0.5rem;
|
|
103
|
+
margin-bottom: 0.65rem;
|
|
104
|
+
}
|
|
105
|
+
.try-it-form input {
|
|
106
|
+
flex: 1 1 8rem;
|
|
107
|
+
font-family: monospace;
|
|
108
|
+
font-size: 0.85rem;
|
|
109
|
+
padding: 0.35rem 0.55rem;
|
|
110
|
+
border: 1px solid var(--rule);
|
|
111
|
+
border-radius: var(--radius);
|
|
112
|
+
background: transparent;
|
|
113
|
+
color: var(--ink);
|
|
114
|
+
}
|
|
115
|
+
.try-it-form input:focus {
|
|
116
|
+
outline: none;
|
|
117
|
+
border-color: var(--ink);
|
|
118
|
+
}
|
|
119
|
+
.derived-key {
|
|
120
|
+
font-size: 0.78rem;
|
|
121
|
+
color: var(--ink-dim);
|
|
122
|
+
padding: 0.35rem 0.55rem;
|
|
123
|
+
border-radius: var(--radius);
|
|
124
|
+
background: rgba(0, 0, 0, 0.03);
|
|
125
|
+
word-break: break-all;
|
|
126
|
+
}
|
|
127
|
+
.derived-key .key-label {
|
|
128
|
+
display: block;
|
|
129
|
+
margin-bottom: 0.2rem;
|
|
130
|
+
font-family: monospace;
|
|
131
|
+
}
|
|
132
|
+
.derived-key .key-value {
|
|
133
|
+
font-family: monospace;
|
|
134
|
+
color: var(--ink);
|
|
135
|
+
}
|
|
136
|
+
.derived-key.computing .key-value { color: var(--ink-dim); font-style: italic; }
|
|
137
|
+
|
|
30
138
|
hr { margin: 2rem 0; }
|
|
31
139
|
|
|
32
140
|
.apps-heading {
|
|
@@ -44,7 +152,9 @@
|
|
|
44
152
|
}
|
|
45
153
|
|
|
46
154
|
.app-card {
|
|
47
|
-
display:
|
|
155
|
+
display: flex;
|
|
156
|
+
flex-direction: column;
|
|
157
|
+
gap: 0.25rem;
|
|
48
158
|
text-decoration: none;
|
|
49
159
|
color: var(--ink);
|
|
50
160
|
border: 1.5px solid var(--ink);
|
|
@@ -56,22 +166,81 @@
|
|
|
56
166
|
.app-card:hover { transform: translate(-1px, -1px); box-shadow: 3px 4px 0 var(--ink); }
|
|
57
167
|
.app-card:active { transform: translate(1px, 2px); box-shadow: none; }
|
|
58
168
|
|
|
59
|
-
.app-name {
|
|
169
|
+
.app-name-row {
|
|
170
|
+
display: flex;
|
|
171
|
+
align-items: baseline;
|
|
172
|
+
gap: 0.45rem;
|
|
173
|
+
}
|
|
174
|
+
.app-glyph { font-size: 1.1rem; }
|
|
175
|
+
.app-name { font-size: 1rem; }
|
|
60
176
|
.app-desc { font-size: 0.78rem; color: var(--ink-dim); line-height: 1.4; }
|
|
61
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
|
+
|
|
62
218
|
.footer {
|
|
219
|
+
display: flex;
|
|
220
|
+
flex-wrap: wrap;
|
|
221
|
+
gap: 0.5rem;
|
|
63
222
|
margin-top: 3rem;
|
|
64
|
-
|
|
223
|
+
}
|
|
224
|
+
.footer-chip {
|
|
225
|
+
font-size: 0.78rem;
|
|
65
226
|
color: var(--ink-dim);
|
|
227
|
+
text-decoration: none;
|
|
228
|
+
padding: 0.25rem 0.6rem;
|
|
229
|
+
border: 1px solid var(--rule);
|
|
230
|
+
border-radius: var(--radius);
|
|
66
231
|
}
|
|
67
|
-
.footer
|
|
232
|
+
.footer-chip:hover { color: var(--ink); border-color: var(--ink); }
|
|
68
233
|
</style>
|
|
69
234
|
</head>
|
|
70
235
|
<body>
|
|
71
236
|
|
|
72
|
-
<
|
|
237
|
+
<a class="wordmark" href="/" title="streamo home"><img src="/streamo.svg" alt="">streamo</a>
|
|
73
238
|
<p class="tagline">every device is an equal author</p>
|
|
74
239
|
|
|
240
|
+
<p class="here" id="here">
|
|
241
|
+
<span class="pulse" id="pulse"></span><span id="here-text">connecting…</span>
|
|
242
|
+
</p>
|
|
243
|
+
|
|
75
244
|
<div class="ideas">
|
|
76
245
|
<div class="idea">
|
|
77
246
|
<span class="idea-glyph">↔</span>
|
|
@@ -91,26 +260,171 @@
|
|
|
91
260
|
</div>
|
|
92
261
|
</div>
|
|
93
262
|
|
|
263
|
+
<details class="try-it">
|
|
264
|
+
<summary>try it — derive a streamo identity right here</summary>
|
|
265
|
+
<p class="try-it-note">
|
|
266
|
+
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.
|
|
267
|
+
</p>
|
|
268
|
+
<div class="try-it-form">
|
|
269
|
+
<input type="text" placeholder="username" id="demo-username" autocomplete="off">
|
|
270
|
+
<input type="password" placeholder="password" id="demo-password" autocomplete="new-password">
|
|
271
|
+
</div>
|
|
272
|
+
<div class="derived-key" id="demo-key-row">
|
|
273
|
+
<span class="key-label">public key (secp256k1):</span>
|
|
274
|
+
<code class="key-value" id="demo-key">— enter credentials above —</code>
|
|
275
|
+
</div>
|
|
276
|
+
</details>
|
|
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
|
+
|
|
94
284
|
<hr>
|
|
95
285
|
|
|
96
286
|
<p class="apps-heading">apps</p>
|
|
97
287
|
<div class="app-grid">
|
|
98
288
|
<a class="app-card" href="/apps/chat/">
|
|
99
|
-
<div class="app-name">
|
|
100
|
-
|
|
289
|
+
<div class="app-name-row">
|
|
290
|
+
<span class="app-glyph">💬</span>
|
|
291
|
+
<span class="app-name">chat</span>
|
|
292
|
+
</div>
|
|
293
|
+
<div class="app-desc">p2p messaging — each participant owns their own signed message stream</div>
|
|
101
294
|
</a>
|
|
102
295
|
<a class="app-card" href="/apps/explorer/">
|
|
103
|
-
<div class="app-name">
|
|
104
|
-
|
|
296
|
+
<div class="app-name-row">
|
|
297
|
+
<span class="app-glyph">🔍</span>
|
|
298
|
+
<span class="app-name">explorer</span>
|
|
299
|
+
</div>
|
|
300
|
+
<div class="app-desc">browse repos, commit history, and the bytes underneath</div>
|
|
105
301
|
</a>
|
|
106
302
|
</div>
|
|
107
303
|
<p class="dim" style="margin-top: 1rem; font-size: 0.78rem;">
|
|
108
304
|
open both side by side — watch commits roll in as you chat
|
|
109
305
|
</p>
|
|
110
306
|
|
|
111
|
-
<
|
|
112
|
-
<a href="https://github.com/dtudury/streamo">github</a>
|
|
113
|
-
|
|
307
|
+
<div class="footer">
|
|
308
|
+
<a class="footer-chip" href="https://github.com/dtudury/streamo">github</a>
|
|
309
|
+
<a class="footer-chip" href="https://github.com/dtudury/streamo/blob/main/design.md">design.md</a>
|
|
310
|
+
<a class="footer-chip" href="https://github.com/dtudury/streamo/blob/main/ROADMAP.md">roadmap</a>
|
|
311
|
+
<a class="footer-chip" href="https://www.npmjs.com/package/@dtudury/streamo">npm</a>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
<script type="module">
|
|
315
|
+
import { Signer } from '/streamo/Signer.js'
|
|
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'
|
|
323
|
+
|
|
324
|
+
// Surface "what's running here" — server's primary key as a clickable
|
|
325
|
+
// link to the explorer. Replaces the abstract "no server holds
|
|
326
|
+
// authority" claim above with a concrete, navigable instance of it.
|
|
327
|
+
const pulseEl = document.getElementById('pulse')
|
|
328
|
+
const hereEl = document.getElementById('here-text')
|
|
329
|
+
let info = null
|
|
330
|
+
try {
|
|
331
|
+
info = await fetch('/api/info').then(r => r.json())
|
|
332
|
+
const truncKey = info.primaryKeyHex.slice(0, 12) + '…'
|
|
333
|
+
hereEl.innerHTML = `relaying <a href="/apps/explorer/#/repo/${info.primaryKeyHex}">${truncKey}</a> as "${info.name || 'this server'}"`
|
|
334
|
+
} catch (e) {
|
|
335
|
+
pulseEl.classList.add('err')
|
|
336
|
+
hereEl.textContent = 'not connected to a streamo server'
|
|
337
|
+
}
|
|
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
|
+
|
|
391
|
+
// Derive-on-type for the try-it widget. Debounced because PBKDF2 is
|
|
392
|
+
// intentionally slow; we don't want to hammer the browser per keystroke.
|
|
393
|
+
const u = document.getElementById('demo-username')
|
|
394
|
+
const p = document.getElementById('demo-password')
|
|
395
|
+
const out = document.getElementById('demo-key')
|
|
396
|
+
const row = document.getElementById('demo-key-row')
|
|
397
|
+
let timer = null
|
|
398
|
+
let inFlight = 0
|
|
399
|
+
function update () {
|
|
400
|
+
clearTimeout(timer)
|
|
401
|
+
const username = u.value.trim()
|
|
402
|
+
const password = p.value
|
|
403
|
+
if (!username || !password) {
|
|
404
|
+
row.classList.remove('computing')
|
|
405
|
+
out.textContent = '— enter credentials above —'
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
row.classList.add('computing')
|
|
409
|
+
out.textContent = 'computing PBKDF2…'
|
|
410
|
+
const me = ++inFlight
|
|
411
|
+
timer = setTimeout(async () => {
|
|
412
|
+
try {
|
|
413
|
+
const signer = new Signer(username, password, 1)
|
|
414
|
+
const { publicKey } = await signer.keysFor('chat')
|
|
415
|
+
if (me !== inFlight) return // user kept typing; stale
|
|
416
|
+
row.classList.remove('computing')
|
|
417
|
+
out.textContent = '0x' + bytesToHex(publicKey)
|
|
418
|
+
} catch (e) {
|
|
419
|
+
if (me !== inFlight) return
|
|
420
|
+
row.classList.remove('computing')
|
|
421
|
+
out.textContent = `error: ${e.message}`
|
|
422
|
+
}
|
|
423
|
+
}, 300)
|
|
424
|
+
}
|
|
425
|
+
u.addEventListener('input', update)
|
|
426
|
+
p.addEventListener('input', update)
|
|
427
|
+
</script>
|
|
114
428
|
|
|
115
429
|
</body>
|
|
116
430
|
</html>
|
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
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<svg viewBox="0 0 680 680" xmlns="http://www.w3.org/2000/svg" fill="#1d4ed8">
|
|
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>
|